Browse Source

Merge branch 'master' into localization

Krzysztof Krysiński 2 years ago
parent
commit
8d8d9502b7
55 changed files with 703 additions and 430 deletions
  1. 0 2
      README.md
  2. 15 4
      src/ChunkyImageLib/ChunkyImage.cs
  3. 2 2
      src/ChunkyImageLib/DataHolders/ShapeCorners.cs
  4. 2 2
      src/ChunkyImageLib/DataHolders/ShapeData.cs
  5. 5 5
      src/ChunkyImageLib/Operations/BresenhamLineOperation.cs
  6. 3 3
      src/ChunkyImageLib/Operations/ChunkyImageOperation.cs
  7. 4 4
      src/ChunkyImageLib/Operations/ClearPathOperation.cs
  8. 3 3
      src/ChunkyImageLib/Operations/ClearRegionOperation.cs
  9. 5 5
      src/ChunkyImageLib/Operations/DrawingSurfaceLineOperation.cs
  10. 3 3
      src/ChunkyImageLib/Operations/EllipseOperation.cs
  11. 1 1
      src/ChunkyImageLib/Operations/IMirroredDrawOperation.cs
  12. 4 4
      src/ChunkyImageLib/Operations/ImageOperation.cs
  13. 4 4
      src/ChunkyImageLib/Operations/PathOperation.cs
  14. 31 4
      src/ChunkyImageLib/Operations/PixelOperation.cs
  15. 4 3
      src/ChunkyImageLib/Operations/PixelsOperation.cs
  16. 4 4
      src/ChunkyImageLib/Operations/RectangleOperation.cs
  17. 1 3
      src/ChunkyImageLib/Surface.cs
  18. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/Size_ChangeInfo.cs
  19. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisPosition_ChangeInfo.cs
  20. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Document.cs
  21. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyDocument.cs
  22. 8 3
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ChangeBrightness_UpdateableChange.cs
  23. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Root/ResizeBasedChangeBase.cs
  24. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Root/ResizeImage_Change.cs
  25. 6 6
      src/PixiEditor.ChangeableDocument/Changes/Root/RotateImage_Change.cs
  26. 5 5
      src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisPosition_UpdateableChange.cs
  27. 10 13
      src/PixiEditor.ChangeableDocument/Changes/Selection/MagicWand/MagicWand_Change.cs
  28. 11 0
      src/PixiEditor.DrawingApi.Core/Numerics/RectD.cs
  29. 10 0
      src/PixiEditor.DrawingApi.Core/Numerics/RectI.cs
  30. 14 0
      src/PixiEditor.DrawingApi.Core/Numerics/VecI.cs
  31. 8 2
      src/PixiEditor/Helpers/Converters/ToolSizeToIntConverter.cs
  32. 34 0
      src/PixiEditor/Helpers/CrashHelper.cs
  33. 21 0
      src/PixiEditor/Helpers/SupportedFilesHelper.cs
  34. 19 25
      src/PixiEditor/Helpers/VersionHelpers.cs
  35. 19 3
      src/PixiEditor/Models/Commands/CommandController.cs
  36. 1 1
      src/PixiEditor/Models/DataHolders/CrashReport.cs
  37. 3 1
      src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs
  38. 0 13
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  39. 7 10
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs
  40. 3 3
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SymmetryExecutor.cs
  41. 24 32
      src/PixiEditor/Models/IO/Exporter.cs
  42. 2 2
      src/PixiEditor/Properties/AssemblyInfo.cs
  43. 1 19
      src/PixiEditor/ViewModels/CrashReportViewModel.cs
  44. 10 2
      src/PixiEditor/ViewModels/SaveFilePopupViewModel.cs
  45. 56 10
      src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs
  46. 25 5
      src/PixiEditor/ViewModels/SubViewModels/Main/DebugViewModel.cs
  47. 8 0
      src/PixiEditor/ViewModels/SubViewModels/Main/FileViewModel.cs
  48. 10 1
      src/PixiEditor/ViewModels/SubViewModels/Main/MiscViewModel.cs
  49. 1 1
      src/PixiEditor/Views/UserControls/Overlays/SymmetryOverlay/SymmetryAxisDragInfo.cs
  50. 18 17
      src/PixiEditor/Views/UserControls/Overlays/SymmetryOverlay/SymmetryOverlay.cs
  51. 112 122
      src/PixiEditorGen/CommandNameListGenerator.cs
  52. 41 18
      windows-x64-release-dev.yml
  53. 40 17
      windows-x64-release.yml
  54. 38 17
      windows-x86-release-dev.yml
  55. 37 16
      windows-x86-release.yml

+ 0 - 2
README.md

@@ -85,8 +85,6 @@ Struggling with something? You can find support in a few places:
 
 * .NET 7
 
-* latest Visual Studio 2022 (in order to code generators to work)
-
 ### Instructions
 
 1. Clone Repository

+ 15 - 4
src/ChunkyImageLib/ChunkyImage.cs

@@ -82,8 +82,8 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     private BlendMode blendMode = BlendMode.Src;
     private bool lockTransparency = false;
     private VectorPath? clippingPath;
-    private int? horizontalSymmetryAxis = null;
-    private int? verticalSymmetryAxis = null;
+    private double? horizontalSymmetryAxis = null;
+    private double? verticalSymmetryAxis = null;
 
     private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> committedChunks;
     private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> latestChunks;
@@ -429,7 +429,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
-    public void SetHorizontalAxisOfSymmetry(int position)
+    public void SetHorizontalAxisOfSymmetry(double position)
     {
         lock (lockObject)
         {
@@ -441,7 +441,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
-    public void SetVerticalAxisOfSymmetry(int position)
+    public void SetVerticalAxisOfSymmetry(double position)
     {
         lock (lockObject)
         {
@@ -606,6 +606,17 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawPixel(VecI pos, PixelProcessor pixelProcessor, BlendMode blendMode)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PixelOperation operation = new(pos, pixelProcessor, blendMode);
+            EnqueueOperation(operation);
+        }
+    }
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
     {

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

@@ -127,7 +127,7 @@ public struct ShapeCorners
         return crossTop == crossRight && crossTop == crossLeft && crossTop == crossBottom;
     }
 
-    public ShapeCorners AsMirroredAcrossHorAxis(int horAxisY) => new ShapeCorners
+    public ShapeCorners AsMirroredAcrossHorAxis(double horAxisY) => new ShapeCorners
     {
         BottomLeft = BottomLeft.ReflectY(horAxisY),
         BottomRight = BottomRight.ReflectY(horAxisY),
@@ -135,7 +135,7 @@ public struct ShapeCorners
         TopRight = TopRight.ReflectY(horAxisY)
     };
 
-    public ShapeCorners AsMirroredAcrossVerAxis(int verAxisX) => new ShapeCorners
+    public ShapeCorners AsMirroredAcrossVerAxis(double verAxisX) => new ShapeCorners
     {
         BottomLeft = BottomLeft.ReflectX(verAxisX),
         BottomRight = BottomRight.ReflectX(verAxisX),

+ 2 - 2
src/ChunkyImageLib/DataHolders/ShapeData.cs

@@ -26,9 +26,9 @@ public record struct ShapeData
     public double Angle { get; }
     public int StrokeWidth { get; }
 
-    public ShapeData AsMirroredAcrossHorAxis(int horAxisY)
+    public ShapeData AsMirroredAcrossHorAxis(double horAxisY)
         => new ShapeData(Center.ReflectY(horAxisY), new(Size.X, -Size.Y), -Angle, StrokeWidth, StrokeColor, FillColor, BlendMode);
-    public ShapeData AsMirroredAcrossVerAxis(int verAxisX)
+    public ShapeData AsMirroredAcrossVerAxis(double verAxisX)
         => new ShapeData(Center.ReflectX(verAxisX), new(-Size.X, Size.Y), -Angle, StrokeWidth, StrokeColor, FillColor, BlendMode);
 
 }

+ 5 - 5
src/ChunkyImageLib/Operations/BresenhamLineOperation.cs

@@ -44,19 +44,19 @@ internal class BresenhamLineOperation : IMirroredDrawOperation
         return new AffectedArea(OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize), bounds);
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         RectI newFrom = new RectI(from, new VecI(1));
         RectI newTo = new RectI(to, new VecI(1));
         if (verAxisX is not null)
         {
-            newFrom = newFrom.ReflectX((int)verAxisX);
-            newTo = newTo.ReflectX((int)verAxisX);
+            newFrom = (RectI)newFrom.ReflectX((double)verAxisX).Round();
+            newTo = (RectI)newTo.ReflectX((double)verAxisX).Round();
         }
         if (horAxisY is not null)
         {
-            newFrom = newFrom.ReflectY((int)horAxisY);
-            newTo = newTo.ReflectY((int)horAxisY);
+            newFrom = (RectI)newFrom.ReflectY((double)horAxisY).Round();
+            newTo = (RectI)newTo.ReflectY((double)horAxisY).Round();
         }
         return new BresenhamLineOperation(newFrom.Pos, newTo.Pos, color, blendMode);
     }

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

@@ -108,13 +108,13 @@ internal class ChunkyImageOperation : IMirroredDrawOperation
         return topLeft;
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         var newPos = targetPos;
         if (verAxisX is not null)
-            newPos = newPos.ReflectX((int)verAxisX);
+            newPos = (VecI)newPos.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            newPos = newPos.ReflectY((int)horAxisY);
+            newPos = (VecI)newPos.ReflectY((double)horAxisY).Round();
         return new ChunkyImageOperation(imageToDraw, newPos, mirrorHorizontal ^ (verAxisX is not null), mirrorVertical ^ (horAxisY is not null));
     }
 

+ 4 - 4
src/ChunkyImageLib/Operations/ClearPathOperation.cs

@@ -38,17 +38,17 @@ internal class ClearPathOperation : IMirroredDrawOperation
         path.Dispose();
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
-        var matrix = Matrix3X3.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, verAxisX ?? 0, horAxisY ?? 0);
+        var matrix = Matrix3X3.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, (float?)verAxisX ?? 0, (float?)horAxisY ?? 0);
         using var copy = new VectorPath(path);
         copy.Transform(matrix);
 
         var newRect = pathTightBounds;
         if (verAxisX is not null)
-            newRect = newRect.ReflectX((int)verAxisX);
+            newRect = (RectI)newRect.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            newRect = newRect.ReflectY((int)horAxisY);
+            newRect = (RectI)newRect.ReflectY((double)horAxisY).Round();
         return new ClearPathOperation(copy, newRect);
     }
 }

+ 3 - 3
src/ChunkyImageLib/Operations/ClearRegionOperation.cs

@@ -31,13 +31,13 @@ internal class ClearRegionOperation : IMirroredDrawOperation
     }
     public void Dispose() { }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         var newRect = rect;
         if (verAxisX is not null)
-            newRect = newRect.ReflectX((int)verAxisX);
+            newRect = (RectI)newRect.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            newRect = newRect.ReflectY((int)horAxisY);
+            newRect = (RectI)newRect.ReflectY((double)horAxisY).Round();
         return new ClearRegionOperation(newRect);
     }
 }

+ 5 - 5
src/ChunkyImageLib/Operations/DrawingSurfaceLineOperation.cs

@@ -44,19 +44,19 @@ internal class DrawingSurfaceLineOperation : IMirroredDrawOperation
         return new AffectedArea(OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize), bounds);
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         VecI newFrom = from;
         VecI newTo = to;
         if (verAxisX is not null)
         {
-            newFrom = newFrom.ReflectX((int)verAxisX);
-            newTo = newTo.ReflectX((int)verAxisX);
+            newFrom = (VecI)newFrom.ReflectX((double)verAxisX).Round();
+            newTo = (VecI)newTo.ReflectX((double)verAxisX).Round();
         }
         if (horAxisY is not null)
         {
-            newFrom = newFrom.ReflectY((int)horAxisY);
-            newTo = newTo.ReflectY((int)horAxisY);
+            newFrom = (VecI)newFrom.ReflectY((double)horAxisY).Round();
+            newTo = (VecI)newTo.ReflectY((double)horAxisY).Round();
         }
         return new DrawingSurfaceLineOperation(newFrom, newTo, paint.StrokeCap, paint.StrokeWidth, paint.Color, paint.BlendMode);
     }

+ 3 - 3
src/ChunkyImageLib/Operations/EllipseOperation.cs

@@ -105,13 +105,13 @@ internal class EllipseOperation : IMirroredDrawOperation
         return new AffectedArea(chunks, location);
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         RectI newLocation = location;
         if (verAxisX is not null)
-            newLocation = newLocation.ReflectX((int)verAxisX);
+            newLocation = (RectI)newLocation.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            newLocation = newLocation.ReflectY((int)horAxisY);
+            newLocation = (RectI)newLocation.ReflectY((double)horAxisY).Round();
         return new EllipseOperation(newLocation, strokeColor, fillColor, strokeWidth, paint);
     }
 

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

@@ -2,5 +2,5 @@
 
 internal interface IMirroredDrawOperation : IDrawOperation
 {
-    IDrawOperation AsMirrored(int? verAxisX, int? horAxisY);
+    IDrawOperation AsMirrored(double? verAxisX, double? horAxisY);
 }

+ 4 - 4
src/ChunkyImageLib/Operations/ImageOperation.cs

@@ -106,22 +106,22 @@ internal class ImageOperation : IMirroredDrawOperation
         customPaint?.Dispose();
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         if (verAxisX is not null && horAxisY is not null)
         {
             return new ImageOperation
-                (corners.AsMirroredAcrossVerAxis((int)verAxisX).AsMirroredAcrossHorAxis((int)horAxisY), toPaint, customPaint, imageWasCopied);
+                (corners.AsMirroredAcrossVerAxis((double)verAxisX).AsMirroredAcrossHorAxis((double)horAxisY), toPaint, customPaint, imageWasCopied);
         }
         if (verAxisX is not null)
         {
             return new ImageOperation
-                (corners.AsMirroredAcrossVerAxis((int)verAxisX), toPaint, customPaint, imageWasCopied);
+                (corners.AsMirroredAcrossVerAxis((double)verAxisX), toPaint, customPaint, imageWasCopied);
         }
         if (horAxisY is not null)
         {
             return new ImageOperation
-                (corners.AsMirroredAcrossHorAxis((int)horAxisY), toPaint, customPaint, imageWasCopied);
+                (corners.AsMirroredAcrossHorAxis((double)horAxisY), toPaint, customPaint, imageWasCopied);
         }
         return new ImageOperation(corners, toPaint, customPaint, imageWasCopied);
     }

+ 4 - 4
src/ChunkyImageLib/Operations/PathOperation.cs

@@ -40,17 +40,17 @@ internal class PathOperation : IMirroredDrawOperation
         return new AffectedArea(OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize), bounds);
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
-        var matrix = Matrix3X3.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, verAxisX ?? 0, horAxisY ?? 0);
+        var matrix = Matrix3X3.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, (float?)verAxisX ?? 0, (float?)horAxisY ?? 0);
         using var copy = new VectorPath(path);
         copy.Transform(matrix);
 
         RectI newBounds = bounds;
         if (verAxisX is not null)
-            newBounds = newBounds.ReflectX((int)verAxisX);
+            newBounds = (RectI)newBounds.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            newBounds = newBounds.ReflectY((int)horAxisY);
+            newBounds = (RectI)newBounds.ReflectY((double)horAxisY).Round();
         return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, paint.BlendMode, newBounds);
     }
 

+ 31 - 4
src/ChunkyImageLib/Operations/PixelOperation.cs

@@ -6,6 +6,7 @@ using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
 
 namespace ChunkyImageLib.Operations;
 
+public delegate Color PixelProcessor(Color input);
 internal class PixelOperation : IMirroredDrawOperation
 {
     public bool IgnoreEmptyChunks => false;
@@ -14,6 +15,8 @@ internal class PixelOperation : IMirroredDrawOperation
     private readonly BlendMode blendMode;
     private readonly Paint paint;
 
+    private readonly PixelProcessor? _colorProcessor = null;
+
     public PixelOperation(VecI pixel, Color color, BlendMode blendMode)
     {
         this.pixel = pixel;
@@ -22,10 +25,18 @@ internal class PixelOperation : IMirroredDrawOperation
         paint = new Paint() { BlendMode = blendMode };
     }
 
+    public PixelOperation(VecI pixel, PixelProcessor colorProcessor, BlendMode blendMode)
+    {
+        this.pixel = pixel;
+        this._colorProcessor = colorProcessor;
+        this.blendMode = blendMode;
+        paint = new Paint() { BlendMode = blendMode };
+    }
+
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
     {
         // a hacky way to make the lines look slightly better on non full res chunks
-        paint.Color = new Color(color.R, color.G, color.B, (byte)(color.A * chunk.Resolution.Multiplier()));
+        paint.Color = GetColor(chunk, chunkPos);
 
         DrawingSurface surf = chunk.Surface.DrawingSurface;
         surf.Canvas.Save();
@@ -35,18 +46,34 @@ internal class PixelOperation : IMirroredDrawOperation
         surf.Canvas.Restore();
     }
 
+    private Color GetColor(Chunk chunk, VecI chunkPos)
+    {
+        Color pixelColor = color;
+        if (_colorProcessor != null)
+        {
+            pixelColor = _colorProcessor(chunk.Surface.GetSRGBPixel(pixel - chunkPos * ChunkyImage.FullChunkSize));
+        }
+
+        return new Color(pixelColor.R, pixelColor.G, pixelColor.B, (byte)(pixelColor.A * chunk.Resolution.Multiplier()));
+    }
+
     public AffectedArea FindAffectedArea(VecI imageSize)
     {
         return new AffectedArea(new HashSet<VecI>() { OperationHelper.GetChunkPos(pixel, ChunkyImage.FullChunkSize) }, new RectI(pixel, VecI.One));
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         RectI pixelRect = new RectI(pixel, new VecI(1, 1));
         if (verAxisX is not null)
-            pixelRect = pixelRect.ReflectX((int)verAxisX);
+            pixelRect = (RectI)pixelRect.ReflectX((double)verAxisX).Round();
         if (horAxisY is not null)
-            pixelRect = pixelRect.ReflectY((int)horAxisY);
+            pixelRect = (RectI)pixelRect.ReflectY((double)horAxisY);
+        if (_colorProcessor != null)
+        {
+            return new PixelOperation(pixelRect.Pos, _colorProcessor, blendMode);
+        }
+
         return new PixelOperation(pixelRect.Pos, color, blendMode);
     }
 

+ 4 - 3
src/ChunkyImageLib/Operations/PixelsOperation.cs

@@ -1,4 +1,5 @@
 using System.ComponentModel.DataAnnotations.Schema;
+using System.Linq;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Numerics;
@@ -52,11 +53,11 @@ internal class PixelsOperation : IMirroredDrawOperation
         return new AffectedArea(affectedChunks, affectedArea);
     }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         var arr = pixels.Select(pixel => new VecI(
-            verAxisX is not null ? 2 * (int)verAxisX - (int)pixel.X - 1 : (int)pixel.X,
-            horAxisY is not null ? 2 * (int)horAxisY - (int)pixel.Y - 1 : (int)pixel.Y
+            verAxisX is not null ? (int)Math.Round(2 * (double)verAxisX - (int)pixel.X - 1) : (int)pixel.X,
+            horAxisY is not null ? (int)Math.Round(2 * (double)horAxisY - (int)pixel.Y - 1) : (int)pixel.Y
         ));
         return new PixelsOperation(arr, color, blendMode);
     }

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

@@ -72,14 +72,14 @@ internal class RectangleOperation : IMirroredDrawOperation
 
     public void Dispose() { }
 
-    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    public IDrawOperation AsMirrored(double? verAxisX, double? horAxisY)
     {
         if (verAxisX is not null && horAxisY is not null)
-            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((int)horAxisY).AsMirroredAcrossVerAxis((int)verAxisX));
+            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((double)horAxisY).AsMirroredAcrossVerAxis((double)verAxisX));
         else if (verAxisX is not null)
-            return new RectangleOperation(Data.AsMirroredAcrossVerAxis((int)verAxisX));
+            return new RectangleOperation(Data.AsMirroredAcrossVerAxis((double)verAxisX));
         else if (horAxisY is not null)
-            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((int)horAxisY));
+            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((double)horAxisY));
         return new RectangleOperation(Data);
     }
 }

+ 1 - 3
src/ChunkyImageLib/Surface.cs

@@ -22,9 +22,7 @@ public class Surface : IDisposable
     public Surface(VecI size)
     {
         if (size.X < 1 || size.Y < 1)
-            throw new ArgumentException("Width and height must be >1");
-        if (size.X > 10000 || size.Y > 10000)
-            throw new ArgumentException("Width and height must be <=10000");
+            throw new ArgumentException("Width and height must be >=1");
 
         Size = size;
 

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/Size_ChangeInfo.cs

@@ -2,4 +2,4 @@
 
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Root;
 
-public record class Size_ChangeInfo(VecI Size, int VerticalSymmetryAxisX, int HorizontalSymmetryAxisY) : IChangeInfo;
+public record class Size_ChangeInfo(VecI Size, double VerticalSymmetryAxisX, double HorizontalSymmetryAxisY) : IChangeInfo;

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisPosition_ChangeInfo.cs

@@ -1,4 +1,4 @@
 using PixiEditor.ChangeableDocument.Enums;
 
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Root;
-public record class SymmetryAxisPosition_ChangeInfo(SymmetryAxisDirection Direction, int NewPosition) : IChangeInfo;
+public record class SymmetryAxisPosition_ChangeInfo(SymmetryAxisDirection Direction, double NewPosition) : IChangeInfo;

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Document.cs

@@ -26,8 +26,8 @@ internal class Document : IChangeable, IReadOnlyDocument, IDisposable
     public VecI Size { get; set; } = DefaultSize;
     public bool HorizontalSymmetryAxisEnabled { get; set; }
     public bool VerticalSymmetryAxisEnabled { get; set; }
-    public int HorizontalSymmetryAxisY { get; set; }
-    public int VerticalSymmetryAxisX { get; set; }
+    public double HorizontalSymmetryAxisY { get; set; }
+    public double VerticalSymmetryAxisX { get; set; }
 
     public void Dispose()
     {

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyDocument.cs

@@ -33,12 +33,12 @@ public interface IReadOnlyDocument
     /// <summary>
     /// The position of the horizontal symmetry axis (Mirrors top and bottom)
     /// </summary>
-    int HorizontalSymmetryAxisY { get; }
+    double HorizontalSymmetryAxisY { get; }
 
     /// <summary>
     /// The position of the vertical symmetry axis (Mirrors left and right)
     /// </summary>
-    int VerticalSymmetryAxisX { get; }
+    double VerticalSymmetryAxisX { get; }
 
     /// <summary>
     /// Performs the specified action on each readonly member of the document

+ 8 - 3
src/PixiEditor.ChangeableDocument/Changes/Drawing/ChangeBrightness_UpdateableChange.cs

@@ -88,9 +88,14 @@ internal class ChangeBrightness_UpdateableChange : UpdateableChange
             
             for (VecI pos = new VecI(left.X, y); pos.X <= right.X; pos.X++)
             {
-                Color pixel = tempSurface.GetSRGBPixel(pos);
-                Color newColor = ColorHelper.ChangeColorBrightness(pixel, correctionFactor);
-                layerImage.EnqueueDrawPixel(pos + offset, newColor, BlendMode.Src);
+                layerImage.EnqueueDrawPixel(
+                    pos + offset,
+                    (pixel) =>
+                    {
+                        Color newColor = ColorHelper.ChangeColorBrightness(pixel, correctionFactor);
+                        return ColorHelper.ChangeColorBrightness(newColor, correctionFactor);
+                    },
+                    BlendMode.Src);
             }
         }
     }

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Root/ResizeBasedChangeBase.cs

@@ -6,8 +6,8 @@ namespace PixiEditor.ChangeableDocument.Changes.Root;
 internal abstract class ResizeBasedChangeBase : Change
 {
     protected VecI _originalSize;
-    protected int _originalHorAxisY;
-    protected int _originalVerAxisX;
+    protected double _originalHorAxisY;
+    protected double _originalVerAxisX;
     protected Dictionary<Guid, CommittedChunkStorage> deletedChunks = new();
     protected Dictionary<Guid, CommittedChunkStorage> deletedMaskChunks = new();
     

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Root/ResizeImage_Change.cs

@@ -12,8 +12,8 @@ internal class ResizeImage_Change : Change
     private readonly VecI newSize;
     private readonly ResamplingMethod method;
     private VecI originalSize;
-    private int originalHorAxisY;
-    private int originalVerAxisX;
+    private double originalHorAxisY;
+    private double originalVerAxisX;
     
     private Dictionary<Guid, CommittedChunkStorage> savedChunks = new();
     private Dictionary<Guid, CommittedChunkStorage> savedMaskChunks = new();

+ 6 - 6
src/PixiEditor.ChangeableDocument/Changes/Root/RotateImage_Change.cs

@@ -14,8 +14,8 @@ internal sealed class RotateImage_Change : Change
     private List<Guid> membersToRotate;
     
     private VecI originalSize;
-    private int originalHorAxisY;
-    private int originalVerAxisX;
+    private double originalHorAxisY;
+    private double originalVerAxisX;
     private Dictionary<Guid, CommittedChunkStorage> deletedChunks = new();
     private Dictionary<Guid, CommittedChunkStorage> deletedMaskChunks = new();
 
@@ -161,12 +161,12 @@ internal sealed class RotateImage_Change : Change
 
         VecI newSize = new VecI(newWidth, newHeight);
 
-        float normalizedSymmX = originalVerAxisX / Math.Max(target.Size.X, 0.1f);
-        float normalizedSymmY = originalHorAxisY / Math.Max(target.Size.Y, 0.1f);
+        double normalizedSymmX = originalVerAxisX / Math.Max(target.Size.X, 0.1f);
+        double normalizedSymmY = originalHorAxisY / Math.Max(target.Size.Y, 0.1f);
 
         target.Size = newSize;
-        target.VerticalSymmetryAxisX = (int)(newSize.X * normalizedSymmX);
-        target.HorizontalSymmetryAxisY = (int)(newSize.Y * normalizedSymmY);
+        target.VerticalSymmetryAxisX = Math.Round(newSize.X * normalizedSymmX * 2) / 2;
+        target.HorizontalSymmetryAxisY = Math.Round(newSize.Y * normalizedSymmY * 2) / 2;
 
         target.ForEveryMember((member) =>
         {

+ 5 - 5
src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisPosition_UpdateableChange.cs

@@ -5,18 +5,18 @@ namespace PixiEditor.ChangeableDocument.Changes.Root;
 internal class SymmetryAxisPosition_UpdateableChange : UpdateableChange
 {
     private readonly SymmetryAxisDirection direction;
-    private int newPos;
-    private int originalPos;
+    private double newPos;
+    private double originalPos;
 
     [GenerateUpdateableChangeActions]
-    public SymmetryAxisPosition_UpdateableChange(SymmetryAxisDirection direction, int pos)
+    public SymmetryAxisPosition_UpdateableChange(SymmetryAxisDirection direction, double pos)
     {
         this.direction = direction;
         newPos = pos;
     }
 
     [UpdateChangeMethod]
-    public void Update(int pos)
+    public void Update(double pos)
     {
         newPos = pos;
     }
@@ -32,7 +32,7 @@ internal class SymmetryAxisPosition_UpdateableChange : UpdateableChange
         return true;
     }
 
-    private void SetPosition(Document target, int position)
+    private void SetPosition(Document target, double position)
     {
         if (direction == SymmetryAxisDirection.Horizontal)
             target.HorizontalSymmetryAxisY = position;

+ 10 - 13
src/PixiEditor.ChangeableDocument/Changes/Selection/MagicWand/MagicWand_Change.cs

@@ -10,19 +10,15 @@ internal class MagicWand_Change : Change
     private VectorPath? originalPath;
     private VectorPath path = new() { FillType = PathFillType.EvenOdd };
     private VecI point;
-    private readonly Guid memberGuid;
-    private readonly bool referenceAll;
-    private readonly bool drawOnMask;
+    private readonly List<Guid> memberGuids;
     private readonly SelectionMode mode;
 
     [GenerateMakeChangeAction]
-    public MagicWand_Change(Guid memberGuid, VecI point, SelectionMode mode, bool referenceAll, bool drawOnMask)
+    public MagicWand_Change(List<Guid> memberGuids, VecI point, SelectionMode mode)
     {
         path.MoveTo(point);
         this.mode = mode;
-        this.memberGuid = memberGuid;
-        this.referenceAll = referenceAll;
-        this.drawOnMask = drawOnMask;
+        this.memberGuids = memberGuids;
         this.point = point;
     }
 
@@ -34,13 +30,14 @@ internal class MagicWand_Change : Change
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
     {
-        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
-
         HashSet<Guid> membersToReference = new();
-        if (referenceAll)
-            target.ForEveryReadonlyMember(member => membersToReference.Add(member.GuidValue));
-        else
-            membersToReference.Add(memberGuid);
+
+        target.ForEveryReadonlyMember(member =>
+        {
+            if (memberGuids.Contains(member.GuidValue))
+                membersToReference.Add(member.GuidValue);
+        });
+
         path = MagicWandHelper.DoMagicWandFloodFill(point, membersToReference, target);
 
         ignoreInUndo = false;

+ 11 - 0
src/PixiEditor.DrawingApi.Core/Numerics/RectD.cs

@@ -260,6 +260,17 @@ public struct RectD : IEquatable<RectD>
         }
     }
 
+    public readonly RectD Round()
+    {
+        return new RectD()
+        {
+            Left = Math.Round(left),
+            Right = Math.Round(right),
+            Top = Math.Round(top),
+            Bottom = Math.Round(bottom)
+        };
+    }
+
     public readonly RectD RoundOutwards()
     {
         return new RectD()

+ 10 - 0
src/PixiEditor.DrawingApi.Core/Numerics/RectI.cs

@@ -149,6 +149,16 @@ public struct RectI : IEquatable<RectI>
         };
     }
 
+    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 RectI ReflectX(int verLineX)
     {
         return RectI.FromTwoPoints(Pos.ReflectX(verLineX), (Pos + Size).ReflectX(verLineX));

+ 14 - 0
src/PixiEditor.DrawingApi.Core/Numerics/VecI.cs

@@ -53,6 +53,20 @@ public struct VecI : IEquatable<VecI>
     {
         return new(X, 2 * lineY - Y);
     }
+    /// <summary>
+    /// Reflects the vector across a vertical line with the specified x position
+    /// </summary>
+    public VecD ReflectX(double lineX)
+    {
+        return new(2 * lineX - X, Y);
+    }
+    /// <summary>
+    /// Reflects the vector across a horizontal line with the specified y position
+    /// </summary>
+    public VecD ReflectY(double lineY)
+    {
+        return new(X, 2 * lineY - Y);
+    }
     public static VecI operator +(VecI a, VecI b)
     {
         return new VecI(a.X + b.X, a.Y + b.Y);

+ 8 - 2
src/PixiEditor/Helpers/Converters/ToolSizeToIntConverter.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Globalization;
+using System.Text;
 using System.Text.RegularExpressions;
 using System.Windows.Data;
 
@@ -28,6 +29,11 @@ internal class ToolSizeToIntConverter
             return null;
         }
 
-        return int.Parse(match.Groups[0].ValueSpan);
+        if (int.TryParse(match.Groups[0].ValueSpan.ToString().Normalize(NormalizationForm.FormKC), out int result))
+        {
+            return result;
+        }
+
+        return null;
     }
-}
+}

+ 34 - 0
src/PixiEditor/Helpers/CrashHelper.cs

@@ -1,4 +1,7 @@
 using System.Globalization;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
 using System.Text;
 using ByteSizeLib;
 using Hardware.Info;
@@ -97,4 +100,35 @@ internal class CrashHelper
             }
         }
     }
+
+    public static async Task SendExceptionInfoToWebhook(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
+    {
+        if (ViewModelMain.Current.DebugSubViewModel.IsDebugBuild)
+            return;
+        await SendReportTextToWebhook(CrashReport.Generate(e), $"{filePath}; Method {memberName}");
+    }
+
+    public static async Task SendReportTextToWebhook(CrashReport report, string catchLocation = null)
+    {
+        string reportText = report.ReportText;
+        if (catchLocation is not null)
+        {
+            reportText = $"The report was generated from an exception caught in {catchLocation}.\r\n{reportText}";
+        }
+
+        byte[] bytes = Encoding.UTF8.GetBytes(reportText);
+        string filename = Path.GetFileNameWithoutExtension(report.FilePath) + ".txt";
+
+        MultipartFormDataContent formData = new MultipartFormDataContent
+        {
+            { new ByteArrayContent(bytes, 0, bytes.Length), "crash-report", filename }
+        };
+        try
+        {
+            using HttpClient httpClient = new HttpClient();
+            string url = BuildConstants.CrashReportWebhookUrl;
+            await httpClient.PostAsync(url, formData);
+        }
+        catch { }
+    }
 }

+ 21 - 0
src/PixiEditor/Helpers/SupportedFilesHelper.cs

@@ -36,6 +36,27 @@ internal class SupportedFilesHelper
         return allFileTypeDialogsData.Where(i => i.FileType == type).Single();
     }
 
+    public static string FixFileExtension(string pathWithOrWithoutExtension, FileType requestedType)
+    {
+        if (requestedType == FileType.Unset)
+            throw new ArgumentException("A valid filetype is required", nameof(requestedType));
+
+        var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithOrWithoutExtension));
+        if (typeFromPath != FileType.Unset && typeFromPath == requestedType)
+            return pathWithOrWithoutExtension;
+        return AppendExtension(pathWithOrWithoutExtension, SupportedFilesHelper.GetFileTypeDialogData(requestedType));
+    }
+
+    public static string AppendExtension(string path, FileTypeDialogData data)
+    {
+        string ext = data.Extensions.First();
+        string filename = Path.GetFileName(path);
+        if (filename.Length + ext.Length > 255)
+            filename = filename.Substring(0, 255 - ext.Length);
+        filename += ext;
+        return Path.Combine(Path.GetDirectoryName(path), filename);
+    }
+
     public static bool IsSupportedFile(string path)
     {
         var ext = Path.GetExtension(path.ToLower());

+ 19 - 25
src/PixiEditor/Helpers/VersionHelpers.cs

@@ -10,37 +10,31 @@ internal static class VersionHelpers
 
     public static string GetCurrentAssemblyVersion(Func<Version, string> toString) => toString(GetCurrentAssemblyVersion());
 
-    public static string GetCurrentAssemblyVersionString()
+    public static string GetCurrentAssemblyVersionString(bool moreSpecific = false)
     {
         StringBuilder builder = new(GetCurrentAssemblyVersion().ToString());
 
-        bool isDone = false;
-
-        AppendDevBuild(builder, ref isDone);
+#if DEVRELEASE
+        builder.Append(" Dev Build");
+        return builder.ToString();
+#elif MSIX_DEBUG
+        builder.Append(" MSIX Debug Build");
+        return builder.ToString();
+#elif DEBUG
+        builder.Append(" Debug Build");
+        return builder.ToString();
+#endif
 
-        if (isDone)
-        {
+        if (!moreSpecific)
             return builder.ToString();
-        }
-
-        AppendDebugBuild(builder, ref isDone);
 
+#if STEAM
+        builder.Append(" Steam Build");
+#elif MSIX
+        builder.Append(" MSIX Build");
+#elif RELEASE
+        builder.Append(" Release Build");
+#endif
         return builder.ToString();
     }
-
-    [Conditional("DEVRELEASE")]
-    private static void AppendDevBuild(StringBuilder builder, ref bool done)
-    {
-        done = true;
-
-        builder.Append(" Dev Build");
-    }
-
-    [Conditional("DEBUG")]
-    private static void AppendDebugBuild(StringBuilder builder, ref bool done)
-    {
-        done = true;
-
-        builder.Append(" Debug Build");
-    }
 }

+ 19 - 3
src/PixiEditor/Models/Commands/CommandController.cs

@@ -285,15 +285,31 @@ internal class CommandController
 
             var parameters = method?.GetParameters();
 
-            Action<object> action;
+            async void ActionOnException(Task faultedTask)
+            {
+                // since this method is "async void" and not "async Task", the runtime will propagate exceptions out if it
+                // (instead of putting them into the returned task and forgetting about them)
+                await faultedTask; // this instantly throws the exception from the already faulted task
+            }
 
+            Action<object> action;
             if (parameters is not { Length: 1 })
             {
-                action = x => method.Invoke(instance, null);
+                action = x =>
+                {
+                    object result = method.Invoke(instance, null);
+                    if (result is Task task)
+                        task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+                };
             }
             else
             {
-                action = x => method.Invoke(instance, new[] { x });
+                action = x =>
+                {
+                    object result = method.Invoke(instance, new[] { x });
+                    if (result is Task task)
+                        task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+                };
             }
 
             string name = attribute.InternalName;

+ 1 - 1
src/PixiEditor/Models/DataHolders/CrashReport.cs

@@ -19,7 +19,7 @@ internal class CrashReport : IDisposable
         DateTime currentTime = DateTime.Now;
 
         builder
-            .AppendLine($"PixiEditor {VersionHelpers.GetCurrentAssemblyVersionString()} crashed on {currentTime:yyyy.MM.dd} at {currentTime:HH:mm:ss}\n")
+            .AppendLine($"PixiEditor {VersionHelpers.GetCurrentAssemblyVersionString(moreSpecific: true)} crashed on {currentTime:yyyy.MM.dd} at {currentTime:HH:mm:ss}\n")
             .AppendLine("-----System Information----")
             .AppendLine("General:")
             .AppendLine($"  OS: {Environment.OSVersion.VersionString}")

+ 3 - 1
src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs

@@ -7,6 +7,7 @@ using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.ChangeInfos;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Helpers;
+using PixiEditor.Models.DocumentPassthroughActions;
 using PixiEditor.Models.Rendering;
 using PixiEditor.Models.Rendering.RenderInfos;
 using PixiEditor.ViewModels.SubViewModels.Document;
@@ -75,6 +76,7 @@ internal class ActionAccumulator
             // update viewmodels based on changes
             List<IChangeInfo> optimizedChanges = ChangeInfoListOptimizer.Optimize(changes);
             bool undoBoundaryPassed = toExecute.Any(static action => action is ChangeBoundary_Action or Redo_Action or Undo_Action);
+            bool viewportRefreshRequest = toExecute.Any(static action => action is RefreshViewport_PassthroughAction);
             foreach (IChangeInfo info in optimizedChanges)
             {
                 internals.Updater.ApplyChangeFromChangeInfo(info);
@@ -104,7 +106,7 @@ internal class ActionAccumulator
             // update the contents of the bitmaps
             var affectedAreas = new AffectedAreasGatherer(internals.Tracker, optimizedChanges);
             List<IRenderInfo> renderResult = new();
-            renderResult.AddRange(await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
+            renderResult.AddRange(await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed || viewportRefreshRequest));
             renderResult.AddRange(await previewUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
 
             // lock bitmaps

+ 0 - 13
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -15,9 +15,6 @@ internal class ChangeExecutionController
     public ShapeCorners LastTransformState { get; private set; }
     public VecI LastPixelPosition => lastPixelPos;
     public VecD LastPrecisePosition => lastPrecisePos;
-    public float LastOpacityValue = 1f;
-    public int LastHorizontalSymmetryAxisPosition { get; private set; }
-    public int LastVerticalSymmetryAxisPosition { get; private set; }
     public bool IsChangeActive => currentSession is not null;
 
     private readonly DocumentViewModel document;
@@ -148,7 +145,6 @@ internal class ChangeExecutionController
     public void OpacitySliderDragStartedInlet() => currentSession?.OnOpacitySliderDragStarted();
     public void OpacitySliderDraggedInlet(float newValue)
     {
-        LastOpacityValue = newValue;
         currentSession?.OnOpacitySliderDragged(newValue);
     }
     public void OpacitySliderDragEndedInlet() => currentSession?.OnOpacitySliderDragEnded();
@@ -156,15 +152,6 @@ internal class ChangeExecutionController
     public void SymmetryDragStartedInlet(SymmetryAxisDirection dir) => currentSession?.OnSymmetryDragStarted(dir);
     public void SymmetryDraggedInlet(SymmetryAxisDragInfo info)
     {
-        switch (info.Direction)
-        {
-            case SymmetryAxisDirection.Horizontal:
-                LastHorizontalSymmetryAxisPosition = info.NewPosition;
-                break;
-            case SymmetryAxisDirection.Vertical:
-                LastVerticalSymmetryAxisPosition = info.NewPosition;
-                break;
-        }
         currentSession?.OnSymmetryDragged(info);
     }
 

+ 7 - 10
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs

@@ -11,28 +11,25 @@ internal class MagicWandToolExecutor : UpdateableChangeExecutor
 {
     private bool considerAllLayers;
     private bool drawOnMask;
-    private Guid memberGuid;
+    private List<Guid> memberGuids;
     private SelectionMode mode;
 
     public override ExecutionState Start()
     {
         var magicWand = ViewModelMain.Current?.ToolsSubViewModel.GetTool<MagicWandToolViewModel>();
-        var member = document!.SelectedStructureMember;
+        var members = document!.ExtractSelectedLayers(true);
 
-        if (magicWand is null || member is null)
-            return ExecutionState.Error;
-        drawOnMask = member is not LayerViewModel layer || layer.ShouldDrawOnMask;
-        if (drawOnMask && !member.HasMaskBindable)
-            return ExecutionState.Error;
-        if (!drawOnMask && member is not LayerViewModel)
+        if (magicWand is null || members.Count == 0)
             return ExecutionState.Error;
 
         mode = magicWand.SelectMode;
-        memberGuid = member.GuidValue;
+        memberGuids = members;
         considerAllLayers = magicWand.DocumentScope == DocumentScope.AllLayers;
+        if (considerAllLayers)
+            memberGuids = document!.StructureHelper.GetAllLayers().Select(x => x.GuidValue).ToList();
         var pos = controller!.LastPixelPosition;
 
-        internals!.ActionAccumulator.AddActions(new MagicWand_Action(memberGuid, pos, mode, considerAllLayers, drawOnMask));
+        internals!.ActionAccumulator.AddActions(new MagicWand_Action(memberGuids, pos, mode));
 
         return ExecutionState.Success;
     }

+ 3 - 3
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/SymmetryExecutor.cs

@@ -18,10 +18,10 @@ internal class SymmetryExecutor : UpdateableChangeExecutor
             !document.VerticalSymmetryAxisEnabledBindable && dir == SymmetryAxisDirection.Vertical)
             return ExecutionState.Error;
 
-        int lastPos = dir switch
+        double lastPos = dir switch
         {
-            SymmetryAxisDirection.Horizontal => controller.LastHorizontalSymmetryAxisPosition,
-            SymmetryAxisDirection.Vertical => controller.LastVerticalSymmetryAxisPosition,
+            SymmetryAxisDirection.Horizontal => document.HorizontalSymmetryAxisYBindable,
+            SymmetryAxisDirection.Vertical => document.VerticalSymmetryAxisXBindable,
             _ => throw new NotImplementedException(),
         };
         internals.ActionAccumulator.AddActions(new SymmetryAxisPosition_Action(dir, lastPos));

+ 24 - 32
src/PixiEditor/Models/IO/Exporter.cs

@@ -1,6 +1,8 @@
 using System.IO;
 using System.IO.Compression;
+using System.Reflection.Metadata;
 using System.Runtime.InteropServices;
+using System.Security;
 using System.Windows;
 using System.Windows.Media.Imaging;
 using ChunkyImageLib;
@@ -22,8 +24,10 @@ internal enum DialogSaveResult
     Success = 0,
     InvalidPath = 1,
     ConcurrencyError = 2,
-    UnknownError = 3,
-    Cancelled = 4,
+    SecurityError = 3,
+    IoError = 4,
+    UnknownError = 5,
+    Cancelled = 6,
 }
 
 internal enum SaveResult
@@ -31,7 +35,9 @@ internal enum SaveResult
     Success = 0,
     InvalidPath = 1,
     ConcurrencyError = 2,
-    UnknownError = 3,
+    SecurityError = 3,
+    IoError = 4,
+    UnknownError = 5,
 }
 
 internal class Exporter
@@ -67,7 +73,7 @@ internal class Exporter
     /// </summary>
     public static SaveResult TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, FileType fileTypeFromDialog, out string finalPath, VecI? exportSize = null)
     {
-        finalPath = FixFileExtension(pathFromDialog, fileTypeFromDialog);
+        finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
         var saveResult = TrySave(document, finalPath, exportSize);
         if (saveResult != SaveResult.Success)
             finalPath = "";
@@ -75,17 +81,6 @@ internal class Exporter
         return saveResult;
     }
 
-    private static string FixFileExtension(string pathWithOrWithoutExtension, FileType requestedType)
-    {
-        if (requestedType == FileType.Unset)
-            throw new ArgumentException("A valid filetype is required", nameof(requestedType));
-
-        var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithOrWithoutExtension));
-        if (typeFromPath != FileType.Unset && typeFromPath == requestedType)
-            return pathWithOrWithoutExtension;
-        return AppendExtension(pathWithOrWithoutExtension, SupportedFilesHelper.GetFileTypeDialogData(requestedType));
-    }
-
     /// <summary>
     /// Attempts to save the document into the given location, filetype is inferred from path
     /// </summary>
@@ -108,9 +103,8 @@ internal class Exporter
             {
                 return SaveResult.UnknownError;
             }
-            
-            if (!TrySaveAs(encodersFactory[typeFromPath](), pathWithExtension, bitmap, exportSize))
-                return SaveResult.UnknownError;
+
+            return TrySaveAs(encodersFactory[typeFromPath](), pathWithExtension, bitmap, exportSize);
         }
         else
         {
@@ -120,16 +114,6 @@ internal class Exporter
         return SaveResult.Success;
     }
 
-    private static string AppendExtension(string path, FileTypeDialogData data)
-    {
-        string ext = data.Extensions.First();
-        string filename = Path.GetFileName(path);
-        if (filename.Length + ext.Length > 255)
-            filename = filename.Substring(0, 255 - ext.Length);
-        filename += ext;
-        return Path.Combine(Path.GetDirectoryName(path), filename);
-    }
-
     static Dictionary<FileType, Func<BitmapEncoder>> encodersFactory = new Dictionary<FileType, Func<BitmapEncoder>>();
 
     static Exporter()
@@ -170,7 +154,7 @@ internal class Exporter
     /// <summary>
     /// Saves image to PNG file. Messes with the passed bitmap.
     /// </summary>
-    private static bool TrySaveAs(BitmapEncoder encoder, string savePath, Surface bitmap, VecI? exportSize)
+    private static SaveResult TrySaveAs(BitmapEncoder encoder, string savePath, Surface bitmap, VecI? exportSize)
     {
         try
         {
@@ -184,10 +168,18 @@ internal class Exporter
             encoder.Frames.Add(BitmapFrame.Create(bitmap.ToWriteableBitmap()));
             encoder.Save(stream);
         }
-        catch (Exception err)
+        catch (SecurityException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
         {
-            return false;
+            return SaveResult.IoError;
         }
-        return true;
+        catch
+        {
+            return SaveResult.UnknownError;
+        }
+        return SaveResult.Success;
     }
 }

+ 2 - 2
src/PixiEditor/Properties/AssemblyInfo.cs

@@ -50,5 +50,5 @@ using System.Windows;
 // You can specify all the values or you can default the Build and Revision Numbers
 // by using the '*' as shown below:
 // [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.1")]
-[assembly: AssemblyFileVersion("1.0.0.1")]
+[assembly: AssemblyVersion("1.0.1.0")]
+[assembly: AssemblyFileVersion("1.0.1.0")]

+ 1 - 19
src/PixiEditor/ViewModels/CrashReportViewModel.cs

@@ -40,25 +40,7 @@ internal class CrashReportViewModel : ViewModelBase
         AttachDebuggerCommand = new(AttachDebugger);
 
         if (!IsDebugBuild)
-            SendReportTextToWebhook(report);
-    }
-
-    private async void SendReportTextToWebhook(CrashReport report)
-    {
-        byte[] bytes = Encoding.UTF8.GetBytes(report.ReportText);
-        string filename = Path.GetFileNameWithoutExtension(report.FilePath) + ".txt";
-
-        MultipartFormDataContent formData = new MultipartFormDataContent
-        {
-            { new ByteArrayContent(bytes, 0, bytes.Length), "crash-report", filename }
-        };
-        try
-        {
-            using HttpClient httpClient = new HttpClient();
-            string url = BuildConstants.CrashReportWebhookUrl;
-            await httpClient.PostAsync(url, formData);
-        }
-        catch { }
+            _ = CrashHelper.SendReportTextToWebhook(report);
     }
 
     public void RecoverDocuments(object args)

+ 10 - 2
src/PixiEditor/ViewModels/SaveFilePopupViewModel.cs

@@ -59,13 +59,21 @@ internal class SaveFilePopupViewModel : ViewModelBase
             Title = "Export path",
             CheckPathExists = true,
             Filter = SupportedFilesHelper.BuildSaveFilter(false),
-            FilterIndex = 0
+            FilterIndex = 0,
+            AddExtension = true
         };
         if (path.ShowDialog() == true)
         {
             if (string.IsNullOrEmpty(path.FileName) == false)
             {
-                ChosenFormat = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(path.SafeFileName));
+                ChosenFormat = SupportedFilesHelper.GetSaveFileTypeFromFilterIndex(false, path.FilterIndex);
+                if (ChosenFormat == FileType.Unset)
+                {
+                    return null;
+                }
+
+                path.FileName = SupportedFilesHelper.FixFileExtension(path.FileName, ChosenFormat);
+
                 return path.FileName;
             }
         }

+ 56 - 10
src/PixiEditor/ViewModels/SubViewModels/Document/DocumentViewModel.cs

@@ -103,11 +103,11 @@ internal partial class DocumentViewModel : NotifyableObject
     public int Height => size.Y;
     public VecI SizeBindable => size;
 
-    private int horizontalSymmetryAxisY;
-    public int HorizontalSymmetryAxisYBindable => horizontalSymmetryAxisY;
+    private double horizontalSymmetryAxisY;
+    public double HorizontalSymmetryAxisYBindable => horizontalSymmetryAxisY;
 
-    private int verticalSymmetryAxisX;
-    public int VerticalSymmetryAxisXBindable => verticalSymmetryAxisX;
+    private double verticalSymmetryAxisX;
+    public double VerticalSymmetryAxisXBindable => verticalSymmetryAxisX;
 
     private readonly HashSet<StructureMemberViewModel> softSelectedStructureMembers = new();
     public IReadOnlyCollection<StructureMemberViewModel> SoftSelectedStructureMembers => softSelectedStructureMembers;
@@ -198,9 +198,9 @@ internal partial class DocumentViewModel : NotifyableObject
         viewModel.Internals.ChangeController.SymmetryDraggedInlet(new SymmetryAxisDragInfo(SymmetryAxisDirection.Vertical, builderInstance.Width / 2));
 
         acc.AddActions(
-            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Horizontal, builderInstance.Height / 2),
+            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Horizontal, (double)builderInstance.Height / 2),
             new EndSymmetryAxisPosition_Action(),
-            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Vertical, builderInstance.Width / 2),
+            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Vertical, (double)builderInstance.Width / 2),
             new EndSymmetryAxisPosition_Action());
 
         if (builderInstance.ReferenceLayer is { } refLayer)
@@ -477,13 +477,13 @@ internal partial class DocumentViewModel : NotifyableObject
         RaisePropertyChanged(nameof(HorizontalSymmetryAxisEnabledBindable));
     }
 
-    public void InternalSetVerticalSymmetryAxisX(int verticalSymmetryAxisX)
+    public void InternalSetVerticalSymmetryAxisX(double verticalSymmetryAxisX)
     {
         this.verticalSymmetryAxisX = verticalSymmetryAxisX;
         RaisePropertyChanged(nameof(VerticalSymmetryAxisXBindable));
     }
 
-    public void InternalSetHorizontalSymmetryAxisY(int horizontalSymmetryAxisY)
+    public void InternalSetHorizontalSymmetryAxisY(double horizontalSymmetryAxisY)
     {
         this.horizontalSymmetryAxisY = horizontalSymmetryAxisY;
         RaisePropertyChanged(nameof(HorizontalSymmetryAxisYBindable));
@@ -521,8 +521,54 @@ internal partial class DocumentViewModel : NotifyableObject
     /// </summary>
     public List<Guid> GetSelectedMembers()
     {
-        List<Guid> layerGuids = new List<Guid>() { SelectedStructureMember.GuidValue };
-        layerGuids.AddRange( SoftSelectedStructureMembers.Select(x => x.GuidValue));
+        List<Guid> layerGuids = new List<Guid>();
+        if (SelectedStructureMember is not null)
+            layerGuids.Add(SelectedStructureMember.GuidValue);
+
+        layerGuids.AddRange(SoftSelectedStructureMembers.Select(x => x.GuidValue));
         return layerGuids;
     }
+
+    public List<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false)
+    {
+        var result = new List<Guid>();
+        List<Guid> selectedMembers = GetSelectedMembers();
+        foreach (var member in selectedMembers)
+        {
+            var foundMember = StructureHelper.Find(member);
+            if (foundMember != null)
+            {
+                if (foundMember is LayerViewModel layer && selectedMembers.Contains(foundMember.GuidValue) && !result.Contains(layer.GuidValue))
+                {
+                    result.Add(layer.GuidValue);
+                }
+                else if (foundMember is FolderViewModel folder && selectedMembers.Contains(foundMember.GuidValue))
+                {
+                    if (includeFoldersWithMask && folder.HasMaskBindable && !result.Contains(folder.GuidValue))
+                        result.Add(folder.GuidValue);
+                    ExtractSelectedLayers(folder, result, includeFoldersWithMask);
+                }
+            }
+        }
+        return result;
+    }
+
+    private void ExtractSelectedLayers(FolderViewModel folder, List<Guid> list,
+        bool includeFoldersWithMask)
+    {
+        foreach (var member in folder.Children)
+        {
+            if (member is LayerViewModel layer && !list.Contains(layer.GuidValue))
+            {
+                list.Add(layer.GuidValue);
+            }
+            else if (member is FolderViewModel childFolder)
+            {
+                if (includeFoldersWithMask && childFolder.HasMaskBindable && !list.Contains(childFolder.GuidValue))
+                    list.Add(childFolder.GuidValue);
+
+                ExtractSelectedLayers(childFolder, list, includeFoldersWithMask);
+            }
+        }
+    }
 }

+ 25 - 5
src/PixiEditor/ViewModels/SubViewModels/Main/DebugViewModel.cs

@@ -67,21 +67,41 @@ internal class DebugViewModel : SubViewModel<ViewModelMain>
     }
 
     [Command.Debug("PixiEditor.Debug.OpenTempDirectory", @"%Temp%\PixiEditor", "OPEN_TEMP_DIR", "OPEN_TEMP_DIR", IconPath = "Folder.png")]
-    [Command.Debug("PixiEditor.Debug.OpenLocalAppDataDirectory", @"%LocalAppData%\PixiEditor", "OPEN_LOCAL_APPDATA_DIR", "OPEN_LOCAL_APPDATA_DIR", IconPath = "Folder.png")]
     [Command.Debug("PixiEditor.Debug.OpenRoamingAppDataDirectory", @"%AppData%\PixiEditor", "OPEN_ROAMING_APPDATA_DIR", "OPEN_ROAMING_APPDATA_DIR", IconPath = "Folder.png")]
-    [Command.Debug("PixiEditor.Debug.OpenCrashReportsDirectory", @"%LocalAppData%\PixiEditor\crash_logs", "OPEN_CRASH_REPORTS_DIR", "OPEN_CRASH_REPORTS_DIR", IconPath = "Folder.png")]
     public static void OpenFolder(string path)
     {
-        string expandedPath = Environment.ExpandEnvironmentVariables(path);
-        if (!Directory.Exists(expandedPath))
+        if (!Directory.Exists(path))
         {
-            NoticeDialog.Show(new LocalizedString("PATH_DOES_NOT_EXIST", expandedPath), "LOCATION_DOES_NOT_EXIST");
+            NoticeDialog.Show(new LocalizedString("PATH_DOES_NOT_EXIST", path), "LOCATION_DOES_NOT_EXIST");
             return;
         }
 
         ProcessHelpers.ShellExecuteEV(path);
     }
     
+
+    [Command.Debug("PixiEditor.Debug.OpenLocalAppDataDirectory", @"%LocalAppData%\PixiEditor", "OPEN_LOCAL_APPDATA_DIR", "OPEN_LOCAL_APPDATA_DIR", IconPath = "Folder.png")]
+    [Command.Debug("PixiEditor.Debug.OpenCrashReportsDirectory", @"%LocalAppData%\PixiEditor\crash_logs", "OPEN_CRASH_REPORTS_DIR", "OPEN_CRASH_REPORTS_DIR", IconPath = "Folder.png")]
+    public static void OpenLocalAppDataFolder(string subDirectory)
+    {
+        var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), subDirectory);
+        OpenFolder(path);
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenRoamingAppDataDirectory", @"PixiEditor", "Open Roaming AppData Directory", "Open Roaming AppData Directory", IconPath = "Folder.png")]
+    public static void OpenAppDataFolder(string subDirectory)
+    {
+        var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), subDirectory);
+        OpenFolder(path);
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenTempDirectory", @"PixiEditor", "Open Temp Directory", "Open Temp Directory", IconPath = "Folder.png")]
+    public static void OpenTempFolder(string subDirectory)
+    {
+        var path = Path.Combine(Path.GetTempPath(), subDirectory);
+        OpenFolder(path);
+    }
+
     [Command.Debug("PixiEditor.Debug.DumpAllCommands", "DUMP_ALL_COMMANDS", "DUMP_ALL_COMMANDS_DESCRIPTIVE")]
     public void DumpAllCommands()
     {

+ 8 - 0
src/PixiEditor/ViewModels/SubViewModels/Main/FileViewModel.cs

@@ -329,6 +329,12 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
             case DialogSaveResult.ConcurrencyError:
                 NoticeDialog.Show("INTERNAL_ERROR", "ERROR_WHILE_SAVING");
                 break;
+            case DialogSaveResult.SecurityError:
+                NoticeDialog.Show(title: "Security error", message: "No rights to write to the specified location.");
+                break;
+            case DialogSaveResult.IoError:
+                NoticeDialog.Show(title: "IO error", message: "Error while writing to disk.");
+                break;
             case DialogSaveResult.UnknownError:
                 NoticeDialog.Show("ERROR", "UNKNOWN_ERROR_SAVING");
                 break;
@@ -363,6 +369,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
         foreach (string path in paths)
         {
+            if (!File.Exists(path))
+                continue;
             documents.Add(new RecentlyOpenedDocument(path));
         }
 

+ 10 - 1
src/PixiEditor/ViewModels/SubViewModels/Main/MiscViewModel.cs

@@ -1,6 +1,7 @@
 using PixiEditor.Helpers;
 using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Dialogs;
 using PixiEditor.Views.Dialogs;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main;
@@ -21,6 +22,14 @@ internal class MiscViewModel : SubViewModel<ViewModelMain>
     [Command.Basic("PixiEditor.Links.OpenOtherLicenses", "https://pixieditor.net/docs/Third-party-licenses", "THIRD_PARTY_LICENSES", "OPEN_THIRD_PARTY_LICENSES", IconPath = "Globe.png")]
     public static void OpenHyperlink(string url)
     {
-        ProcessHelpers.ShellExecute(url);
+        try
+        {
+            ProcessHelpers.ShellExecute(url);
+        }
+        catch (Exception e)
+        {
+            NoticeDialog.Show(title: "Error", message: $"Couldn't open the address {url} in your default browser");
+            await CrashHelper.SendExceptionInfoToWebhook(e);
+        }
     }
 }

+ 1 - 1
src/PixiEditor/Views/UserControls/Overlays/SymmetryOverlay/SymmetryAxisDragInfo.cs

@@ -2,4 +2,4 @@
 
 namespace PixiEditor.Views.UserControls.SymmetryOverlay;
 #nullable enable
-internal record class SymmetryAxisDragInfo(SymmetryAxisDirection Direction, int NewPosition);
+internal record class SymmetryAxisDragInfo(SymmetryAxisDirection Direction, double NewPosition);

+ 18 - 17
src/PixiEditor/Views/UserControls/Overlays/SymmetryOverlay/SymmetryOverlay.cs

@@ -18,22 +18,22 @@ namespace PixiEditor.Views.UserControls.Overlays.SymmetryOverlay;
 internal class SymmetryOverlay : Control
 {
     public static readonly DependencyProperty HorizontalAxisYProperty =
-        DependencyProperty.Register(nameof(HorizontalAxisY), typeof(int), typeof(SymmetryOverlay),
-            new(0, OnPositionUpdate));
+        DependencyProperty.Register(nameof(HorizontalAxisY), typeof(double), typeof(SymmetryOverlay),
+            new(0.0, OnPositionUpdate));
 
-    public int HorizontalAxisY
+    public double HorizontalAxisY
     {
-        get => (int)GetValue(HorizontalAxisYProperty);
+        get => (double)GetValue(HorizontalAxisYProperty);
         set => SetValue(HorizontalAxisYProperty, value);
     }
 
     public static readonly DependencyProperty VerticalAxisXProperty =
-        DependencyProperty.Register(nameof(VerticalAxisX), typeof(int), typeof(SymmetryOverlay),
-            new(0, OnPositionUpdate));
+        DependencyProperty.Register(nameof(VerticalAxisX), typeof(double), typeof(SymmetryOverlay),
+            new(0.0, OnPositionUpdate));
 
-    public int VerticalAxisX
+    public double VerticalAxisX
     {
-        get => (int)GetValue(VerticalAxisXProperty);
+        get => (double)GetValue(VerticalAxisXProperty);
         set => SetValue(VerticalAxisXProperty, value);
     }
 
@@ -114,8 +114,8 @@ internal class SymmetryOverlay : Control
 
     private double PenThickness => 1.0 / ZoomboxScale;
 
-    private int horizontalAxisY;
-    private int verticalAxisX;
+    private double horizontalAxisY;
+    private double verticalAxisX;
 
     private MouseUpdateController mouseUpdateController;
 
@@ -334,7 +334,7 @@ internal class SymmetryOverlay : Control
         UpdateHovered(null);
     }
 
-    private void CallSymmetryDragCommand(SymmetryAxisDirection direction, int position)
+    private void CallSymmetryDragCommand(SymmetryAxisDirection direction, double position)
     {
         SymmetryAxisDragInfo dragInfo = new(direction, position);
         if (DragCommand is not null && DragCommand.CanExecute(dragInfo))
@@ -373,8 +373,6 @@ internal class SymmetryOverlay : Control
 
     protected void MouseMoved(object sender, MouseEventArgs e)
     {
-        /*base.OnMouseMove(e);*/
-
         var pos = ToVecD(e.GetPosition(this));
         UpdateHovered(IsTouchingHandle(pos));
 
@@ -382,22 +380,25 @@ internal class SymmetryOverlay : Control
             return;
         if (capturedDirection == SymmetryAxisDirection.Horizontal)
         {
-            horizontalAxisY = (int)Math.Round(Math.Clamp(pos.Y, 0, ActualHeight));
+            horizontalAxisY = Math.Round(Math.Clamp(pos.Y, 0, ActualHeight) * 2) / 2;
 
             if (Keyboard.IsKeyDown(Key.LeftShift))
             {
-                horizontalAxisY = (int)(Math.Round(horizontalAxisY / RenderSize.Height * 8) / 8 * RenderSize.Height);
+                double temp = Math.Round(horizontalAxisY / RenderSize.Height * 8) / 8 * RenderSize.Height;
+                horizontalAxisY = Math.Round(temp * 2) / 2;
             }
 
             CallSymmetryDragCommand((SymmetryAxisDirection)capturedDirection, horizontalAxisY);
         }
         else if (capturedDirection == SymmetryAxisDirection.Vertical)
         {
-            verticalAxisX = (int)Math.Round(Math.Clamp(pos.X, 0, ActualWidth));
+            verticalAxisX = Math.Round(Math.Clamp(pos.X, 0, ActualWidth) * 2) / 2;
 
             if (Keyboard.IsKeyDown(Key.LeftShift))
             {
-                verticalAxisX = (int)(Math.Round(verticalAxisX / RenderSize.Width * 8) / 8 * RenderSize.Width);
+
+                double temp = Math.Round(verticalAxisX / RenderSize.Width * 8) / 8 * RenderSize.Width;
+                verticalAxisX = Math.Round(temp * 2) / 2;
             }
 
             CallSymmetryDragCommand((SymmetryAxisDirection)capturedDirection, verticalAxisX);

+ 112 - 122
src/PixiEditorGen/CommandNameListGenerator.cs

@@ -1,5 +1,6 @@
-using System.Text;
+using System.Collections.Immutable;
 using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
 
 namespace PixiEditorGen;
@@ -7,171 +8,131 @@ namespace PixiEditorGen;
 [Generator(LanguageNames.CSharp)]
 public class CommandNameListGenerator : IIncrementalGenerator
 {
-    private const string Command = "PixiEditor.Models.Commands.Attributes.Commands";
+    private const string Commands = "PixiEditor.Models.Commands.Attributes.Commands";
 
     private const string Evaluators = "PixiEditor.Models.Commands.Attributes.Evaluators.Evaluator";
 
     private const string Groups = "PixiEditor.Models.Commands.Attributes.Commands.Command.GroupAttribute";
-    
+
     public void Initialize(IncrementalGeneratorInitializationContext context)
     {
-        var commandList = context.SyntaxProvider.CreateSyntaxProvider(
-            (x, token) =>
-        {
-            return x is MethodDeclarationSyntax method && method.AttributeLists.Count > 0;
-        }, static (context, cancelToken) =>
-        {
-            var method = (MethodDeclarationSyntax)context.Node;
+        var commandList = CreateSyntaxProvider<Command>(context, Commands).Where(x => x != null);
+        var evaluatorList = CreateSyntaxProvider<Command>(context, Evaluators).Where(x => x != null);
+        var groupList = CreateSyntaxProvider<Group>(context, Groups).Where(x => x != null);
 
-            if (!HasCommandAttribute(method, context, cancelToken, Command))
-                return (null, null, null);
-
-            var symbol = context.SemanticModel.GetDeclaredSymbol(method, cancelToken);
-
-            if (symbol is IMethodSymbol methodSymbol)
-            {
-                if (methodSymbol.ReceiverType == null)
-                    return (null, null, null);
-                
-                return (methodSymbol.ReceiverType.ToDisplayString(), methodSymbol.Name, methodSymbol.Parameters.Select(x => x.ToDisplayString()));
-            }
-            else
-            {
-                return (null, null, null);
-            }
-        }).Where(x => x.Item1 != null);
+        context.RegisterSourceOutput(commandList.Collect(), (context, commands) => AddSource(context, commands, "Commands"));
+        context.RegisterSourceOutput(evaluatorList.Collect(), (context, evaluators) => AddSource(context, evaluators, "Evaluators"));
+        context.RegisterSourceOutput(groupList.Collect(), AddGroupsSource);
+    }
 
-        var evaluatorList = context.SyntaxProvider.CreateSyntaxProvider(
+    private IncrementalValuesProvider<T?> CreateSyntaxProvider<T>(IncrementalGeneratorInitializationContext context, string className) where T : CommandMember<T>
+    {
+        return context.SyntaxProvider.CreateSyntaxProvider(
             (x, token) =>
             {
-                return x is MethodDeclarationSyntax method && method.AttributeLists.Count > 0;
-            }, static (context, cancelToken) =>
-            {
-                var method = (MethodDeclarationSyntax)context.Node;
-
-                if (!HasCommandAttribute(method, context, cancelToken, Evaluators))
-                    return (null, null, null);
-
-                var symbol = context.SemanticModel.GetDeclaredSymbol(method, cancelToken);
-
-                if (symbol is IMethodSymbol methodSymbol)
+                if (typeof(T) == typeof(Command))
                 {
-                    return (methodSymbol.ReceiverType.ToDisplayString(), methodSymbol.Name, methodSymbol.Parameters.Select(x => x.ToDisplayString()));
+                    return x is MethodDeclarationSyntax method && method.AttributeLists.Count > 0;
                 }
                 else
                 {
-                    return (null, null, null);
+                    return x is TypeDeclarationSyntax type && type.AttributeLists.Count > 0;
                 }
-            }).Where(x => x.Item1 != null);
-        
-        var groupList = context.SyntaxProvider.CreateSyntaxProvider(
-            (x, token) =>
-            {
-                return x is TypeDeclarationSyntax type && type.AttributeLists.Count > 0;
-            }, static (context, cancelToken) =>
+            }, (context, cancelToken) =>
             {
-                var method = (TypeDeclarationSyntax)context.Node;
+                var member = (MemberDeclarationSyntax)context.Node;
 
-                if (!HasCommandAttribute(method, context, cancelToken, Groups))
+                if (!HasCommandAttribute(member, context, cancelToken, className))
                     return null;
 
-                var symbol = context.SemanticModel.GetDeclaredSymbol(method, cancelToken);
+                var symbol = context.SemanticModel.GetDeclaredSymbol(member, cancelToken);
+
+                if (symbol is IMethodSymbol methodSymbol && typeof(T) == typeof(Command))
+                {
+                    if (methodSymbol.ReceiverType == null)
+                        return null;
 
-                if (symbol is ITypeSymbol methodSymbol)
+                    return (T)(object)new Command(methodSymbol);
+                }
+                else if (symbol is ITypeSymbol typeSymbol && typeof(T) == typeof(Group))
                 {
-                    return methodSymbol.ToDisplayString();
+                    return (T)(object)new Group(typeSymbol);
                 }
                 else
                 {
                     return null;
                 }
-            }).Where(x => x != null);
-
-        context.RegisterSourceOutput(commandList.Collect(), static (context, methodNames) =>
-        {
-            var code = new StringBuilder(
-                @"namespace PixiEditor.Models.Commands;
-
-internal partial class CommandNameList {
-    partial void AddCommands() {");
+            });
+    }
 
-            List<string> createdClasses = new List<string>();
+    private void AddSource(SourceProductionContext context, ImmutableArray<Command> methodNames, string name)
+    {
+        List<string> createdClasses = new List<string>();
+        SyntaxList<StatementSyntax> statements = new SyntaxList<StatementSyntax>();
 
-            foreach (var method in methodNames)
+        foreach (var methodName in methodNames)
+        {
+            if (!createdClasses.Contains(methodName.OwnerTypeName))
             {
-                if (!createdClasses.Contains(method.Item1))
-                {
-                    code.AppendLine($"      Commands.Add(typeof({method.Item1}), new());");
-                    createdClasses.Add(method.Item1);
-                }
-
-                var parameters = string.Join(",", method.Item3.Select(x => $"typeof({x})"));
-                
-                bool hasParameters = parameters.Length > 0;
-                string paramString = hasParameters ? $"new Type[] {{ {parameters} }}" : "Array.Empty<Type>()";
-                
-                code.AppendLine($"      Commands[typeof({method.Item1})].Add((\"{method.Item2}\", {paramString}));");
+                statements = statements.Add(SyntaxFactory.ParseStatement($"{name}.Add(typeof({methodName.OwnerTypeName}), new());"));
+                createdClasses.Add(methodName.OwnerTypeName);
             }
 
-            code.Append("   }\n}");
+            var parameters = string.Join(",", methodName.ParameterTypeNames);
+            string paramString = parameters.Length > 0 ? $"new Type[] {{ {parameters} }}" : "Array.Empty<Type>()";
 
-            context.AddSource("CommandNameList+Commands", code.ToString());
-        });
-        
-        context.RegisterSourceOutput(evaluatorList.Collect(), static (context, methodNames) =>
-        {
-            var code = new StringBuilder(
-                @"namespace PixiEditor.Models.Commands;
+            statements = statements.Add(SyntaxFactory.ParseStatement($"{name}[typeof({methodName.OwnerTypeName})].Add((\"{methodName.MethodName}\", {paramString}));"));
+        }
 
-internal partial class CommandNameList {
-    partial void AddEvaluators() {");
+        // partial void Add$name$()
+        var method = SyntaxFactory
+            .MethodDeclaration(SyntaxFactory.ParseTypeName("void"), $"Add{name}")
+            .AddModifiers(SyntaxFactory.Token(SyntaxKind.PartialKeyword))
+            .WithBody(SyntaxFactory.Block(statements));
 
-            List<string> createdClasses = new List<string>();
+        // internal partial class CommandNameList
+        var cDecl = SyntaxFactory
+            .ClassDeclaration("CommandNameList")
+            .AddModifiers(SyntaxFactory.Token(SyntaxKind.InternalKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword))
+            .AddMembers(method);
 
-            foreach (var method in methodNames)
-            {
-                if (!createdClasses.Contains(method.Item1))
-                {
-                    code.AppendLine($"      Evaluators.Add(typeof({method.Item1}), new());");
-                    createdClasses.Add(method.Item1);
-                }
+        // namespace PixiEditor.Models.Commands
+        var nspace = SyntaxFactory
+            .NamespaceDeclaration(SyntaxFactory.ParseName("PixiEditor.Models.Commands"))
+            .AddMembers(cDecl);
 
-                if (method.Item3 == null || !method.Item3.Any())
-                {
-                    code.AppendLine($"      Evaluators[typeof({method.Item1})].Add((\"{method.Item2}\", Array.Empty<Type>()));");
-                }
-                else
-                {
-                    var parameters = string.Join(",", method.Item3.Select(x => $"typeof({x})"));
-                    string paramString = parameters.Length > 0 ? $"new Type[] {{ {parameters} }}" : "Array.Empty<Type>()";
-                    code.AppendLine($"      Evaluators[typeof({method.Item1})].Add((\"{method.Item2}\", {paramString}));");
-                }
-            }
+        context.AddSource($"CommandNameList+{name}", nspace.NormalizeWhitespace().ToFullString());
+    }
 
-            code.Append("   }\n}");
+    private void AddGroupsSource(SourceProductionContext context, ImmutableArray<Group> groups)
+    {
+        SyntaxList<StatementSyntax> statements = new SyntaxList<StatementSyntax>();
 
-            context.AddSource("CommandNameList+Evaluators", code.ToString());
-        });
-        
-        context.RegisterSourceOutput(groupList.Collect(), static (context, typeNames) =>
+        foreach (var group in groups)
         {
-            var code = new StringBuilder(
-                @"namespace PixiEditor.Models.Commands;
+            statements = statements.Add(SyntaxFactory.ParseStatement($"Groups.Add(typeof({group.OwnerTypeName}));"));
+        }
 
-internal partial class CommandNameList {
-    partial void AddGroups() {");
+        // partial void AddGroups()
+        var method = SyntaxFactory
+            .MethodDeclaration(SyntaxFactory.ParseTypeName("void"), "AddGroups")
+            .AddModifiers(SyntaxFactory.Token(SyntaxKind.PartialKeyword))
+            .WithBody(SyntaxFactory.Block(statements));
 
-            foreach (var name in typeNames)
-            {
-                code.AppendLine($"      Groups.Add(typeof({name}));");
-            }
+        // internal partial class CommandNameList
+        var cDecl = SyntaxFactory
+            .ClassDeclaration("CommandNameList")
+            .AddModifiers(SyntaxFactory.Token(SyntaxKind.InternalKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword))
+            .AddMembers(method);
 
-            code.Append("   }\n}");
+        // namespace PixiEditor.Models.Commands
+        var nspace = SyntaxFactory
+            .NamespaceDeclaration(SyntaxFactory.ParseName("PixiEditor.Models.Commands"))
+            .AddMembers(cDecl);
 
-            context.AddSource("CommandNameList+Groups", code.ToString());
-        });
+        context.AddSource("CommandNameList+Groups", nspace.NormalizeWhitespace().ToFullString());
     }
-    
+
     private static bool HasCommandAttribute(MemberDeclarationSyntax declaration, GeneratorSyntaxContext context, CancellationToken token, string commandAttributeStart)
     {
         foreach (var attrList in declaration.AttributeLists)
@@ -191,4 +152,33 @@ internal partial class CommandNameList {
 
         return false;
     }
+
+    class CommandMember<TSelf> where TSelf : CommandMember<TSelf>
+    {
+        public string OwnerTypeName { get; }
+
+        public CommandMember(string ownerTypeName)
+        {
+            OwnerTypeName = ownerTypeName;
+        }
+    }
+
+    class Command : CommandMember<Command>
+    {
+        public string MethodName { get; }
+
+        public string[] ParameterTypeNames { get; }
+
+        public Command(IMethodSymbol symbol) : base(symbol.ContainingType.ToDisplayString())
+        {
+            MethodName = symbol.Name;
+            ParameterTypeNames = symbol.Parameters.Select(x => $"typeof({x.Type.ToDisplayString()})").ToArray();
+        }
+    }
+
+    class Group : CommandMember<Group>
+    {
+        public Group(ITypeSymbol symbol) : base(symbol.ToDisplayString())
+        { }
+    }
 }

+ 41 - 18
windows-x64-release-dev.yml

@@ -5,7 +5,6 @@
 
 trigger:
 - development
-
 pr: none
 
 pool:
@@ -15,27 +14,34 @@ variables:
 - group: Release Secrets
 - name: solution 
   value: '**/*.sln'
+- name: archNumber
+  value: '64'
+- name: architecture
+  value: 'x$(archNumber)'
 - name: buildPlatform 
-  value: 'x64'
+  value: 'win-$(architecture)'
 - name: buildConfiguration
-  value: 'Release'
+  value: 'DevRelease'
 
 steps:
+
 - task: UseDotNet@2
   inputs:
     packageType: 'sdk'
-    version: '7.0.103'
+    version: '7.0.203'
+
 - task: NuGetToolInstaller@1
 
 - task: NuGetCommand@2
   inputs:
     restoreSolution: '$(solution)'
 
-- task: VSBuild@1
+- task: DotNetCoreCLI@2
+  displayName: "Build PixiEditor Solution"
   inputs:
-    solution: '$(solution)'
-    platform: '$(buildPlatform)'
-    configuration: '$(buildConfiguration)'
+    command: 'build'
+    projects: 'src/PixiEditor'
+    arguments: '-r "$(buildPlatform)" -c $(buildConfiguration)'
 
 - task: DotNetCoreCLI@2
   displayName: "Build release PixiEditor.UpdateInstaller"
@@ -43,9 +49,10 @@ steps:
     command: 'publish'
     publishWebProjects: false
     projects: '**/PixiEditor.UpdateInstaller.csproj'
-    arguments: '-o "UpdateInstaller" -r "win-x64" --self-contained=false -p:PublishSingleFile=true -c Release'
+    arguments: '-o "UpdateInstaller" -r "$(buildPlatform)" --self-contained=false -p:PublishSingleFile=true -c $(buildConfiguration)'
     zipAfterPublish: false
 
+
 - task: PowerShell@2
   displayName: "Set tag version"
   inputs:
@@ -55,22 +62,22 @@ steps:
   displayName: Publish PixiEditor
   inputs:
     filePath: 'src/PixiEditor.Builder/build.ps1'
-    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "DevRelease" --runtime "win-x64" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-x64-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
+    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "$(buildConfiguration)" --runtime "$(buildPlatform)" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-$(architecture)-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
     workingDirectory: 'src/PixiEditor.Builder'
 
 - task: ArchiveFiles@2
   inputs:
-    rootFolderOrFile: 'Builds\PixiEditor-x64-light'
+    rootFolderOrFile: 'Builds\PixiEditor-$(architecture)-light'
     includeRootFolder: false
     archiveType: 'zip'
-    archiveFile: 'PixiEditor.$(TagVersion).x64.zip'
+    archiveFile: 'PixiEditor.$(TagVersion).$(architecture).zip'
     replaceExistingArchive: true
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish zip artifact"
   inputs:
-    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).x64.zip'
-    artifact: 'PixiEditor.$(TagVersion).x64.zip'
+    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).$(architecture).zip'
+    artifact: 'PixiEditor.$(TagVersion).$(architecture).zip'
     publishLocation: 'pipeline'
 
 - task: CopyFiles@2
@@ -78,18 +85,34 @@ steps:
   inputs:
     SourceFolder: 'UpdateInstaller'
     Contents: '**'
-    TargetFolder: 'Builds/PixiEditor-x64-light/PixiEditor'
+    TargetFolder: 'Builds/PixiEditor-$(architecture)-light/PixiEditor'
     flattenFolders: true
 
 - task: PowerShell@2
   displayName: "Compile installer"
   inputs:
     targetType: 'inline'
-    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-x64-light.iss'
+    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-$(architecture)-light.iss'
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish artifact"
   inputs:
-    targetPath: 'src/Installer/Assets/PixiEditor-x64-light/'
-    artifact: 'PixiEditor-setup-x64.exe'
+    targetPath: 'src/Installer/Assets/PixiEditor-$(architecture)-light/'
+    artifact: 'PixiEditor-setup-$(architecture).exe'
+    publishLocation: 'pipeline'
+
+- task: ArchiveFiles@2
+  displayName: "Create zipped installer"
+  inputs:
+    rootFolderOrFile: 'src/Installer/Assets/PixiEditor-$(architecture)-light'
+    includeRootFolder: false
+    archiveType: 'zip'
+    archiveFile: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    replaceExistingArchive: true
+
+- task: PublishPipelineArtifact@1
+  displayName: "Publish installer zip artifact"
+  inputs:
+    targetPath: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    artifact: 'PixiEditor-$(TagVersion)-setup$(archNumber).zip'
     publishLocation: 'pipeline'

+ 40 - 17
windows-x64-release.yml

@@ -5,7 +5,6 @@
 
 trigger:
 - release
-
 pr: none
 
 pool:
@@ -15,27 +14,34 @@ variables:
 - group: Release Secrets
 - name: solution 
   value: '**/*.sln'
+- name: archNumber
+  value: '64'
+- name: architecture
+  value: 'x$(archNumber)'
 - name: buildPlatform 
-  value: 'x64'
+  value: 'win-$(architecture)'
 - name: buildConfiguration
   value: 'Release'
 
 steps:
+
 - task: UseDotNet@2
   inputs:
     packageType: 'sdk'
-    version: '7.0.103'
+    version: '7.0.203'
+
 - task: NuGetToolInstaller@1
 
 - task: NuGetCommand@2
   inputs:
     restoreSolution: '$(solution)'
 
-- task: VSBuild@1
+- task: DotNetCoreCLI@2
+  displayName: "Build PixiEditor Solution"
   inputs:
-    solution: '$(solution)'
-    platform: '$(buildPlatform)'
-    configuration: '$(buildConfiguration)'
+    command: 'build'
+    projects: 'src/PixiEditor'
+    arguments: '-r "$(buildPlatform)" -c $(buildConfiguration)'
 
 - task: DotNetCoreCLI@2
   displayName: "Build release PixiEditor.UpdateInstaller"
@@ -43,9 +49,10 @@ steps:
     command: 'publish'
     publishWebProjects: false
     projects: '**/PixiEditor.UpdateInstaller.csproj'
-    arguments: '-o "UpdateInstaller" -r "win-x64" --self-contained=false -p:PublishSingleFile=true -c Release'
+    arguments: '-o "UpdateInstaller" -r "$(buildPlatform)" --self-contained=false -p:PublishSingleFile=true -c $(buildConfiguration)'
     zipAfterPublish: false
 
+
 - task: PowerShell@2
   displayName: "Set tag version"
   inputs:
@@ -55,22 +62,22 @@ steps:
   displayName: Publish PixiEditor
   inputs:
     filePath: 'src/PixiEditor.Builder/build.ps1'
-    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "Release" --runtime "win-x64" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-x64-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
+    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "$(buildConfiguration)" --runtime "$(buildPlatform)" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-$(architecture)-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
     workingDirectory: 'src/PixiEditor.Builder'
 
 - task: ArchiveFiles@2
   inputs:
-    rootFolderOrFile: 'Builds\PixiEditor-x64-light'
+    rootFolderOrFile: 'Builds\PixiEditor-$(architecture)-light'
     includeRootFolder: false
     archiveType: 'zip'
-    archiveFile: 'PixiEditor.$(TagVersion).x64.zip'
+    archiveFile: 'PixiEditor.$(TagVersion).$(architecture).zip'
     replaceExistingArchive: true
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish zip artifact"
   inputs:
-    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).x64.zip'
-    artifact: 'PixiEditor.$(TagVersion).x64.zip'
+    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).$(architecture).zip'
+    artifact: 'PixiEditor.$(TagVersion).$(architecture).zip'
     publishLocation: 'pipeline'
 
 - task: CopyFiles@2
@@ -78,18 +85,34 @@ steps:
   inputs:
     SourceFolder: 'UpdateInstaller'
     Contents: '**'
-    TargetFolder: 'Builds/PixiEditor-x64-light/PixiEditor'
+    TargetFolder: 'Builds/PixiEditor-$(architecture)-light/PixiEditor'
     flattenFolders: true
 
 - task: PowerShell@2
   displayName: "Compile installer"
   inputs:
     targetType: 'inline'
-    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-x64-light.iss'
+    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-$(architecture)-light.iss'
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish artifact"
   inputs:
-    targetPath: 'src/Installer/Assets/PixiEditor-x64-light/'
-    artifact: 'PixiEditor-setup-x64.exe'
+    targetPath: 'src/Installer/Assets/PixiEditor-$(architecture)-light/'
+    artifact: 'PixiEditor-setup-$(architecture).exe'
+    publishLocation: 'pipeline'
+
+- task: ArchiveFiles@2
+  displayName: "Create zipped installer"
+  inputs:
+    rootFolderOrFile: 'src/Installer/Assets/PixiEditor-$(architecture)-light'
+    includeRootFolder: false
+    archiveType: 'zip'
+    archiveFile: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    replaceExistingArchive: true
+
+- task: PublishPipelineArtifact@1
+  displayName: "Publish installer zip artifact"
+  inputs:
+    targetPath: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    artifact: 'PixiEditor-$(TagVersion)-setup$(archNumber).zip'
     publishLocation: 'pipeline'

+ 38 - 17
windows-x86-release-dev.yml

@@ -14,17 +14,21 @@ variables:
 - group: Release Secrets
 - name: solution 
   value: '**/*.sln'
+- name: archNumber
+  value: '86'
+- name: architecture
+  value: 'x$(archNumber)'
 - name: buildPlatform 
-  value: 'x86'
+  value: 'win-$(architecture)'
 - name: buildConfiguration
-  value: 'Release'
+  value: 'DevRelease'
 
 steps:
 
 - task: UseDotNet@2
   inputs:
     packageType: 'sdk'
-    version: '7.0.103'
+    version: '7.0.203'
 
 - task: NuGetToolInstaller@1
 
@@ -32,11 +36,12 @@ steps:
   inputs:
     restoreSolution: '$(solution)'
 
-- task: VSBuild@1
+- task: DotNetCoreCLI@2
+  displayName: "Build PixiEditor Solution"
   inputs:
-    solution: '$(solution)'
-    platform: '$(buildPlatform)'
-    configuration: '$(buildConfiguration)'
+    command: 'build'
+    projects: 'src/PixiEditor'
+    arguments: '-r "$(buildPlatform)" -c $(buildConfiguration)'
 
 - task: DotNetCoreCLI@2
   displayName: "Build release PixiEditor.UpdateInstaller"
@@ -44,7 +49,7 @@ steps:
     command: 'publish'
     publishWebProjects: false
     projects: '**/PixiEditor.UpdateInstaller.csproj'
-    arguments: '-o "UpdateInstaller" -r "win-x86" --self-contained=false -p:PublishSingleFile=true -c Release'
+    arguments: '-o "UpdateInstaller" -r "$(buildPlatform)" --self-contained=false -p:PublishSingleFile=true -c $(buildConfiguration)'
     zipAfterPublish: false
 
 
@@ -57,22 +62,22 @@ steps:
   displayName: Publish PixiEditor
   inputs:
     filePath: 'src/PixiEditor.Builder/build.ps1'
-    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "DevRelease" --runtime "win-x86" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-x86-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
+    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "$(buildConfiguration)" --runtime "$(buildPlatform)" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-$(architecture)-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
     workingDirectory: 'src/PixiEditor.Builder'
 
 - task: ArchiveFiles@2
   inputs:
-    rootFolderOrFile: 'Builds\PixiEditor-x86-light'
+    rootFolderOrFile: 'Builds\PixiEditor-$(architecture)-light'
     includeRootFolder: false
     archiveType: 'zip'
-    archiveFile: 'PixiEditor.$(TagVersion).x86.zip'
+    archiveFile: 'PixiEditor.$(TagVersion).$(architecture).zip'
     replaceExistingArchive: true
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish zip artifact"
   inputs:
-    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).x86.zip'
-    artifact: 'PixiEditor.$(TagVersion).x86.zip'
+    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).$(architecture).zip'
+    artifact: 'PixiEditor.$(TagVersion).$(architecture).zip'
     publishLocation: 'pipeline'
 
 - task: CopyFiles@2
@@ -80,18 +85,34 @@ steps:
   inputs:
     SourceFolder: 'UpdateInstaller'
     Contents: '**'
-    TargetFolder: 'Builds/PixiEditor-x86-light/PixiEditor'
+    TargetFolder: 'Builds/PixiEditor-$(architecture)-light/PixiEditor'
     flattenFolders: true
 
 - task: PowerShell@2
   displayName: "Compile installer"
   inputs:
     targetType: 'inline'
-    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-x86-light.iss'
+    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-$(architecture)-light.iss'
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish artifact"
   inputs:
-    targetPath: 'src/Installer/Assets/PixiEditor-x86-light/'
-    artifact: 'PixiEditor-setup-x86.exe'
+    targetPath: 'src/Installer/Assets/PixiEditor-$(architecture)-light/'
+    artifact: 'PixiEditor-setup-$(architecture).exe'
+    publishLocation: 'pipeline'
+
+- task: ArchiveFiles@2
+  displayName: "Create zipped installer"
+  inputs:
+    rootFolderOrFile: 'src/Installer/Assets/PixiEditor-$(architecture)-light'
+    includeRootFolder: false
+    archiveType: 'zip'
+    archiveFile: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    replaceExistingArchive: true
+
+- task: PublishPipelineArtifact@1
+  displayName: "Publish installer zip artifact"
+  inputs:
+    targetPath: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    artifact: 'PixiEditor-$(TagVersion)-setup$(archNumber).zip'
     publishLocation: 'pipeline'

+ 37 - 16
windows-x86-release.yml

@@ -14,8 +14,12 @@ variables:
 - group: Release Secrets
 - name: solution 
   value: '**/*.sln'
+- name: archNumber
+  value: '86'
+- name: architecture
+  value: 'x$(archNumber)'
 - name: buildPlatform 
-  value: 'x86'
+  value: 'win-$(architecture)'
 - name: buildConfiguration
   value: 'Release'
 
@@ -24,7 +28,7 @@ steps:
 - task: UseDotNet@2
   inputs:
     packageType: 'sdk'
-    version: '7.0.103'
+    version: '7.0.203'
 
 - task: NuGetToolInstaller@1
 
@@ -32,11 +36,12 @@ steps:
   inputs:
     restoreSolution: '$(solution)'
 
-- task: VSBuild@1
+- task: DotNetCoreCLI@2
+  displayName: "Build PixiEditor Solution"
   inputs:
-    solution: '$(solution)'
-    platform: '$(buildPlatform)'
-    configuration: '$(buildConfiguration)'
+    command: 'build'
+    projects: 'src/PixiEditor'
+    arguments: '-r "$(buildPlatform)" -c $(buildConfiguration)'
 
 - task: DotNetCoreCLI@2
   displayName: "Build release PixiEditor.UpdateInstaller"
@@ -44,7 +49,7 @@ steps:
     command: 'publish'
     publishWebProjects: false
     projects: '**/PixiEditor.UpdateInstaller.csproj'
-    arguments: '-o "UpdateInstaller" -r "win-x86" --self-contained=false -p:PublishSingleFile=true -c Release'
+    arguments: '-o "UpdateInstaller" -r "$(buildPlatform)" --self-contained=false -p:PublishSingleFile=true -c $(buildConfiguration)'
     zipAfterPublish: false
 
 
@@ -57,22 +62,22 @@ steps:
   displayName: Publish PixiEditor
   inputs:
     filePath: 'src/PixiEditor.Builder/build.ps1'
-    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "Release" --runtime "win-x86" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-x86-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
+    arguments: '--project-path "$(System.DefaultWorkingDirectory)\src\PixiEditor" --build-configuration "$(buildConfiguration)" --runtime "$(buildPlatform)" -o "$(System.DefaultWorkingDirectory)\Builds\PixiEditor-$(architecture)-light\PixiEditor" --crash-report-webhook-url "$(crash-webhook-url)"'
     workingDirectory: 'src/PixiEditor.Builder'
 
 - task: ArchiveFiles@2
   inputs:
-    rootFolderOrFile: 'Builds\PixiEditor-x86-light'
+    rootFolderOrFile: 'Builds\PixiEditor-$(architecture)-light'
     includeRootFolder: false
     archiveType: 'zip'
-    archiveFile: 'PixiEditor.$(TagVersion).x86.zip'
+    archiveFile: 'PixiEditor.$(TagVersion).$(architecture).zip'
     replaceExistingArchive: true
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish zip artifact"
   inputs:
-    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).x86.zip'
-    artifact: 'PixiEditor.$(TagVersion).x86.zip'
+    targetPath: '$(System.DefaultWorkingDirectory)\PixiEditor.$(TagVersion).$(architecture).zip'
+    artifact: 'PixiEditor.$(TagVersion).$(architecture).zip'
     publishLocation: 'pipeline'
 
 - task: CopyFiles@2
@@ -80,18 +85,34 @@ steps:
   inputs:
     SourceFolder: 'UpdateInstaller'
     Contents: '**'
-    TargetFolder: 'Builds/PixiEditor-x86-light/PixiEditor'
+    TargetFolder: 'Builds/PixiEditor-$(architecture)-light/PixiEditor'
     flattenFolders: true
 
 - task: PowerShell@2
   displayName: "Compile installer"
   inputs:
     targetType: 'inline'
-    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-x86-light.iss'
+    script: '& "$env:userprofile\.nuget\packages\tools.innosetup\6.2.1\tools\ISCC.exe" src\Installer\installer-setup-$(architecture)-light.iss'
 
 - task: PublishPipelineArtifact@1
   displayName: "Publish artifact"
   inputs:
-    targetPath: 'src/Installer/Assets/PixiEditor-x86-light/'
-    artifact: 'PixiEditor-setup-x86.exe'
+    targetPath: 'src/Installer/Assets/PixiEditor-$(architecture)-light/'
+    artifact: 'PixiEditor-setup-$(architecture).exe'
+    publishLocation: 'pipeline'
+
+- task: ArchiveFiles@2
+  displayName: "Create zipped installer"
+  inputs:
+    rootFolderOrFile: 'src/Installer/Assets/PixiEditor-$(architecture)-light'
+    includeRootFolder: false
+    archiveType: 'zip'
+    archiveFile: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    replaceExistingArchive: true
+
+- task: PublishPipelineArtifact@1
+  displayName: "Publish installer zip artifact"
+  inputs:
+    targetPath: '$(Build.ArtifactStagingDirectory)/PixiEditor-$(TagVersion)-setup$(archNumber).zip'
+    artifact: 'PixiEditor-$(TagVersion)-setup$(archNumber).zip'
     publishLocation: 'pipeline'