瀏覽代碼

Utterly overengineered path based pen

Equbuxu 3 年之前
父節點
當前提交
5b2113d7de

+ 22 - 2
src/ChunkyImageLib/ChunkyImage.cs

@@ -59,6 +59,14 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 
     public VecI CommittedSize { get; private set; }
     public VecI LatestSize { get; private set; }
+    public int QueueLength
+    {
+        get
+        {
+            lock (lockObject)
+                return queuedOperations.Count;
+        }
+    }
 
     private List<(IOperation operation, HashSet<VecI> affectedChunks)> queuedOperations = new();
     private List<ChunkyImage> activeClips = new();
@@ -356,6 +364,17 @@ 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)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PathOperation operation = new(path, color, strokeWidth, strokeCap, customBounds);
+            EnqueueOperation(operation);
+        }
+    }
+
     public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
     {
         lock (lockObject)
@@ -623,14 +642,15 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// <returns>
     /// Chunks affected by operations that haven't been committed yet
     /// </returns>
-    public HashSet<VecI> FindAffectedChunks()
+    public HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0)
     {
         lock (lockObject)
         {
             ThrowIfDisposed();
             var chunks = new HashSet<VecI>();
-            foreach (var (_, opChunks) in queuedOperations)
+            for (int i = fromOperationIndex; i < queuedOperations.Count; i++)
             {
+                var (_, opChunks) = queuedOperations[i];
                 chunks.UnionWith(opChunks);
             }
             return chunks;

+ 1 - 1
src/ChunkyImageLib/IReadOnlyChunkyImage.cs

@@ -7,7 +7,7 @@ public interface IReadOnlyChunkyImage
 {
     bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null);
     bool LatestOrCommittedChunkExists(VecI chunkPos);
-    HashSet<VecI> FindAffectedChunks();
+    HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0);
     HashSet<VecI> FindCommittedChunks();
     HashSet<VecI> FindAllChunks();
 }

+ 59 - 0
src/ChunkyImageLib/Operations/PathOperation.cs

@@ -0,0 +1,59 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class PathOperation : IDrawOperation
+{
+    private readonly SKPath path;
+
+    private readonly SKPaint paint;
+    private readonly VecI boundsTopLeft;
+    private readonly VecI boundsSize;
+
+    public bool IgnoreEmptyChunks => false;
+
+    public PathOperation(SKPath path, SKColor color, float strokeWidth, SKStrokeCap cap, SKRect? 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;
+    }
+
+    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.DrawPath(path, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksTouchingRectangle(boundsTopLeft, boundsSize, ChunkyImage.FullChunkSize);
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var matrix = SKMatrix.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, verAxisX ?? 0, horAxisY ?? 0);
+        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));
+    }
+
+    public void Dispose()
+    {
+        path.Dispose();
+        paint.Dispose();
+    }
+}

+ 164 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/PathBasedPen_UpdateableChange.cs

@@ -0,0 +1,164 @@
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class PathBasedPen_UpdateableChange : UpdateableChange
+{
+    private readonly Guid memberGuid;
+    private readonly SKColor color;
+    private readonly float strokeWidth;
+    private readonly bool drawOnMask;
+
+    bool firstApply = true;
+
+    private CommittedChunkStorage? storedChunks;
+    private SKPath tempPath = new();
+
+    private List<VecD> points = new();
+
+    [GenerateUpdateableChangeActions]
+    public PathBasedPen_UpdateableChange(Guid memberGuid, VecD pos, SKColor color, float strokeWidth, bool drawOnMask)
+    {
+        this.memberGuid = memberGuid;
+        this.color = color;
+        this.strokeWidth = strokeWidth;
+        this.drawOnMask = drawOnMask;
+        points.Add(pos);
+    }
+
+    [UpdateChangeMethod]
+    public void Update(VecD pos)
+    {
+        points.Add(pos);
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        if (!DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask))
+            return new Error();
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        DrawingChangeHelper.ApplyClipsSymmetriesEtc(target, image, memberGuid, drawOnMask);
+        return new Success();
+    }
+
+    private static (VecD, VecD) FindCubicPoints(VecD prev, VecD mid1, VecD mid2, VecD next)
+    {
+        var ampl = (mid1 - mid2).Length / 3;
+        var vec1 = (mid2 - prev).Normalize() * ampl;
+        var vec2 = (mid1 - next).Normalize() * ampl;
+        return (mid1 + vec1, mid2 + vec2);
+    }
+
+    private void FastforwardEnqueueDrawPath(ChunkyImage image)
+    {
+        for (int i = 0; i < points.Count; i++)
+        {
+            UpdateTempPath(i + 1);
+            image.EnqueueDrawPath(tempPath, color, strokeWidth, SKStrokeCap.Round);
+        }
+    }
+
+    private void UpdateTempPathFinish()
+    {
+        tempPath.Reset();
+        if (points.Count == 1)
+        {
+            tempPath.MoveTo((SKPoint)points[0]);
+            return;
+        }
+        if (points.Count == 2)
+        {
+            tempPath.MoveTo((SKPoint)points[0]);
+            tempPath.LineTo((SKPoint)points[1]);
+            return;
+        }
+        var (mid, _) = FindCubicPoints(points[^3], points[^2], points[^1], points[^1]);
+        tempPath.MoveTo((SKPoint)points[^2]);
+        tempPath.QuadTo((SKPoint)mid, (SKPoint)points[^1]);
+        return;
+    }
+
+    private void UpdateTempPath(int pointsCount)
+    {
+        tempPath.Reset();
+        if (pointsCount is 1 or 2)
+        {
+            tempPath.MoveTo((SKPoint)points[0]);
+            return;
+        }
+        if (pointsCount == 3)
+        {
+            var (mid, _) = FindCubicPoints(points[0], points[1], points[2], points[2]);
+            tempPath.MoveTo((SKPoint)points[0]);
+            tempPath.QuadTo((SKPoint)mid, (SKPoint)points[2]);
+            return;
+        }
+
+        var (mid1, mid2) = FindCubicPoints(points[pointsCount - 4], points[pointsCount - 3], points[pointsCount - 2], points[pointsCount - 1]);
+        tempPath.MoveTo((SKPoint)points[pointsCount - 3]);
+        tempPath.CubicTo((SKPoint)mid1, (SKPoint)mid2, (SKPoint)points[pointsCount - 2]);
+        return;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
+    {
+        if (storedChunks is not null)
+            throw new InvalidOperationException("Trying to save chunks while there are saved chunks already");
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+
+        ignoreInUndo = false;
+        if (firstApply)
+        {
+            firstApply = false;
+            UpdateTempPathFinish();
+
+            image.EnqueueDrawPath(tempPath, color, strokeWidth, SKStrokeCap.Round);
+            var affChunks = image.FindAffectedChunks();
+            storedChunks = new CommittedChunkStorage(image, affChunks);
+            image.CommitChanges();
+
+            return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affChunks, drawOnMask);
+        }
+        else
+        {
+            DrawingChangeHelper.ApplyClipsSymmetriesEtc(target, image, memberGuid, drawOnMask);
+
+            FastforwardEnqueueDrawPath(image);
+            var affChunks = image.FindAffectedChunks();
+            storedChunks = new CommittedChunkStorage(image, affChunks);
+            image.CommitChanges();
+
+            return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affChunks, drawOnMask);
+        }
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        UpdateTempPath(points.Count);
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+
+        int opCount = image.QueueLength;
+        image.EnqueueDrawPath(tempPath, color, strokeWidth, SKStrokeCap.Round);
+        var affChunks = image.FindAffectedChunks(opCount);
+
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affChunks, drawOnMask);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        if (storedChunks is null)
+            throw new InvalidOperationException("No saved chunks to revert to");
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        storedChunks.ApplyChunksToImage(image);
+        var affected = image.FindAffectedChunks();
+        image.CommitChanges();
+        storedChunks.Dispose();
+        storedChunks = null;
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affected, drawOnMask);
+    }
+
+    public override void Dispose()
+    {
+        storedChunks?.Dispose();
+        tempPath.Dispose();
+    }
+}

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

@@ -3,6 +3,7 @@
 internal enum Tool
 {
     Rectangle,
+    PathBasedPen,
     Select,
     Lasso,
     ShiftLayer,

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

@@ -196,6 +196,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
     private bool selectingRect = false;
     private bool selectingLasso = false;
     private bool drawingRectangle = false;
+    private bool drawingPathBasedPen = false;
     private bool transformingRectangle = false;
     private bool shiftingLayer = false;
 
@@ -223,6 +224,32 @@ internal class DocumentViewModel : INotifyPropertyChanged
         Helpers.ActionAccumulator.AddFinishedActions(new EndSymmetryAxisPosition_Action());
     }
 
+    public void StartUpdatePathBasedPen(VecD pos)
+    {
+        if (SelectedStructureMember is null)
+            return;
+        bool drawOnMask = SelectedStructureMember.HasMask && SelectedStructureMember.ShouldDrawOnMask;
+        if (SelectedStructureMember is not LayerViewModel && !drawOnMask)
+            return;
+        updateableChangeActive = true;
+        drawingPathBasedPen = true;
+        Helpers.ActionAccumulator.AddActions(new PathBasedPen_Action(
+            SelectedStructureMember.GuidValue,
+            pos,
+            new SKColor(owner.SelectedColor.R, owner.SelectedColor.G, owner.SelectedColor.B, owner.SelectedColor.A),
+            owner.StrokeWidth,
+            drawOnMask));
+    }
+
+    public void EndPathBasedPen()
+    {
+        if (!drawingPathBasedPen)
+            return;
+        drawingPathBasedPen = false;
+        updateableChangeActive = false;
+        Helpers.ActionAccumulator.AddFinishedActions(new EndPathBasedPen_Action());
+    }
+
     public void StartUpdateRectangle(ShapeData data)
     {
         if (SelectedStructureMember is null)

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

@@ -22,7 +22,8 @@ internal class ViewModelMain : INotifyPropertyChanged
     public RelayCommand SetSelectionModeCommand { get; }
 
     public Color SelectedColor { get; set; } = Colors.Black;
-    public bool KeepOriginalImageOnTransform { get; set; }
+    public bool KeepOriginalImageOnTransform { get; set; } = false;
+    public float StrokeWidth { get; set; } = 1f;
 
     public event PropertyChangedEventHandler? PropertyChanged;
 
@@ -64,8 +65,7 @@ internal class ViewModelMain : INotifyPropertyChanged
     private Dictionary<Guid, DocumentViewModel> documents = new();
     private Guid activeDocumentGuid;
 
-    private int mouseDownCanvasX = 0;
-    private int mouseDownCanvasY = 0;
+    private VecD mouseDownCanvasPos;
 
     private bool mouseHasMoved = false;
     private bool mouseIsDown = false;
@@ -115,17 +115,24 @@ internal class ViewModelMain : INotifyPropertyChanged
         var args = (MouseButtonEventArgs)(param!);
         var source = (System.Windows.Controls.Image)args.Source;
         var pos = args.GetPosition(source);
-        mouseDownCanvasX = (int)(pos.X / source.Width * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelWidth);
-        mouseDownCanvasY = (int)(pos.Y / source.Height * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelHeight);
+        mouseDownCanvasPos = new()
+        {
+            X = pos.X / source.Width * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelWidth,
+            Y = pos.Y / source.Height * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelHeight
+        };
         toolOnMouseDown = activeTool;
-        ProcessToolMouseDown(mouseDownCanvasX, mouseDownCanvasY);
+        ProcessToolMouseDown(mouseDownCanvasPos);
     }
 
-    private void ProcessToolMouseDown(int canvasX, int canvasY)
+    private void ProcessToolMouseDown(VecD pos)
     {
         if (toolOnMouseDown is Tool.FloodFill)
         {
-            ActiveDocument!.FloodFill(new VecI(canvasX, canvasY), new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
+            ActiveDocument!.FloodFill((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
+        }
+        else if (toolOnMouseDown == Tool.PathBasedPen)
+        {
+            ActiveDocument!.StartUpdatePathBasedPen(pos);
         }
     }
 
@@ -137,21 +144,20 @@ internal class ViewModelMain : INotifyPropertyChanged
         var args = (MouseEventArgs)(param!);
         var source = (System.Windows.Controls.Image)args.Source;
         var pos = args.GetPosition(source);
-        int curX = (int)(pos.X / source.Width * ActiveDocument!.Bitmaps[ChunkResolution.Full].PixelWidth);
-        int curY = (int)(pos.Y / source.Height * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelHeight);
+        double curX = pos.X / source.Width * ActiveDocument!.Bitmaps[ChunkResolution.Full].PixelWidth;
+        double curY = pos.Y / source.Height * ActiveDocument.Bitmaps[ChunkResolution.Full].PixelHeight;
 
-        ProcessToolMouseMove(curX, curY);
+        ProcessToolMouseMove(new VecD(curX, curY));
     }
 
-    private void ProcessToolMouseMove(int canvasX, int canvasY)
+    private void ProcessToolMouseMove(VecD canvasPos)
     {
         if (toolOnMouseDown == Tool.Rectangle)
         {
-            int width = canvasX - mouseDownCanvasX;
-            int height = canvasY - mouseDownCanvasY;
+            VecI size = (VecI)canvasPos - (VecI)mouseDownCanvasPos;
             ActiveDocument!.StartUpdateRectangle(new ShapeData(
-                        new(mouseDownCanvasX + width / 2.0, mouseDownCanvasY + height / 2.0),
-                        new(width, height),
+                        (VecI)mouseDownCanvasPos + size / 2,
+                        size,
                         0,
                         90,
                         new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A),
@@ -160,17 +166,21 @@ internal class ViewModelMain : INotifyPropertyChanged
         else if (toolOnMouseDown == Tool.Select)
         {
             ActiveDocument!.StartUpdateRectSelection(
-                new(mouseDownCanvasX, mouseDownCanvasY),
-                new(canvasX - mouseDownCanvasX, canvasY - mouseDownCanvasY),
+                (VecI)mouseDownCanvasPos,
+                (VecI)canvasPos - (VecI)mouseDownCanvasPos,
                 selectionMode);
         }
         else if (toolOnMouseDown == Tool.ShiftLayer)
         {
-            ActiveDocument!.StartUpdateShiftLayer(new(canvasX - mouseDownCanvasX, canvasY - mouseDownCanvasY));
+            ActiveDocument!.StartUpdateShiftLayer((VecI)canvasPos - (VecI)mouseDownCanvasPos);
         }
         else if (toolOnMouseDown == Tool.Lasso)
         {
-            ActiveDocument!.StartUpdateLassoSelection(new(canvasX, canvasY), selectionMode);
+            ActiveDocument!.StartUpdateLassoSelection((VecI)canvasPos, selectionMode);
+        }
+        else if (toolOnMouseDown == Tool.PathBasedPen)
+        {
+            ActiveDocument!.StartUpdatePathBasedPen(canvasPos);
         }
     }
 
@@ -204,6 +214,12 @@ internal class ViewModelMain : INotifyPropertyChanged
             }
         }
 
+        switch (toolOnMouseDown)
+        {
+            case Tool.PathBasedPen:
+                ActiveDocument!.EndPathBasedPen();
+                break;
+        }
     }
 
     private void ChangeActiveTool(object? param)

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

@@ -16,7 +16,7 @@
         xmlns:chen="clr-namespace:PixiEditor.ChangeableDocument.Enums;assembly=PixiEditor.ChangeableDocument"
         xmlns:colorpicker="clr-namespace:ColorPicker;assembly=ColorPicker"
         mc:Ignorable="d"
-        Title="MainWindow" Height="700" Width="1400">
+        Title="MainWindow" Height="800" Width="1500">
     <Window.DataContext>
         <vm:ViewModelMain/>
     </Window.DataContext>
@@ -201,6 +201,8 @@
                     <Button Width="100" Margin="5" Command="{Binding ActiveDocument.ApplyTransformCommand}">Apply Transform</Button>
                 </StackPanel>
                 <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
+                    <Label>Pen size:</Label>
+                    <TextBox Width="30" Margin="5" Text="{Binding StrokeWidth}"/>
                     <TextBox Width="30" Margin="5" Text="{Binding ActiveDocument.ResizeWidth}"/>
                     <TextBox Width="30" Margin="5" Text="{Binding ActiveDocument.ResizeHeight}"/>
                     <Button Width="50" Margin="5" Command="{Binding ActiveDocument.ResizeCanvasCommand}">Resize</Button>
@@ -210,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.PathBasedPen}">Path Pen</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.Select}">Select</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.Lasso}">Lasso</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.ShiftLayer}">Shift Layer</Button>