Browse Source

Merge branch 'master' into vector-layers

flabbet 10 months ago
parent
commit
dbf3718c3f
40 changed files with 742 additions and 111 deletions
  1. 64 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Context/FuncContext.cs
  2. 46 10
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineColorNode.cs
  3. 37 26
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateColorNode.cs
  4. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs
  5. 46 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NodeVariableAttachments.cs
  6. 149 0
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/BuiltInFunctions.cs
  7. 6 3
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Float1.cs
  8. 39 0
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half3.cs
  9. 26 0
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half3Float1Accessor.cs
  10. 12 6
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half4.cs
  11. 27 0
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half4Float1Accessor.cs
  12. 19 10
      src/PixiEditor.DrawingApi.Core/Shaders/Generation/ShaderBuilder.cs
  13. 43 0
      src/PixiEditor.Linux/LinuxOperatingSystem.cs
  14. 3 0
      src/PixiEditor.MacOs/MacOperatingSystem.cs
  15. 3 0
      src/PixiEditor.OperatingSystem/IOperatingSystem.cs
  16. 3 0
      src/PixiEditor.Windows/WindowsOperatingSystem.cs
  17. BIN
      src/PixiEditor/Data/BetaExampleFiles/Island.pixi
  18. BIN
      src/PixiEditor/Data/BetaExampleFiles/Pond.pixi
  19. BIN
      src/PixiEditor/Data/BetaExampleFiles/Stars.pixi
  20. BIN
      src/PixiEditor/Data/BetaExampleFiles/Tree.pixi
  21. 7 0
      src/PixiEditor/Exceptions/CommandInvocationException.cs
  22. 12 3
      src/PixiEditor/Helpers/CrashHelper.cs
  23. 13 0
      src/PixiEditor/Helpers/Extensions/CombineSeparateColorModeExtensions.cs
  24. 1 1
      src/PixiEditor/Helpers/VersionHelpers.cs
  25. 1 0
      src/PixiEditor/Models/AnalyticsAPI/AnalyticEventTypes.cs
  26. 8 2
      src/PixiEditor/Models/AnalyticsAPI/AnalyticSessionInfo.cs
  27. 4 2
      src/PixiEditor/Models/AnalyticsAPI/AnalyticsClient.cs
  28. 25 7
      src/PixiEditor/Models/AnalyticsAPI/AnalyticsPeriodicReporter.cs
  29. 12 4
      src/PixiEditor/Models/Commands/CommandController.cs
  30. 1 1
      src/PixiEditor/Models/Events/NodePropertyValueChanged.cs
  31. 15 11
      src/PixiEditor/Models/ExceptionHandling/CrashReport.cs
  32. 16 0
      src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs
  33. 18 0
      src/PixiEditor/Models/ExceptionHandling/CrashedSessionInfo.cs
  34. 2 2
      src/PixiEditor/Properties/AssemblyInfo.cs
  35. 5 1
      src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/CombineColorNodeViewModel.cs
  36. 53 0
      src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/CombineSeparateColorNodeViewModel.cs
  37. 1 1
      src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/SeparateColorNodeViewModel.cs
  38. 1 1
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  39. 9 9
      src/PixiEditor/Views/MainWindow.axaml.cs
  40. 14 6
      src/PixiEditor/Views/Overlays/TransformOverlay/TransformUpdateHelper.cs

+ 64 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Context/FuncContext.cs

@@ -1,5 +1,6 @@
 using System.Linq.Expressions;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
@@ -98,10 +99,10 @@ public class FuncContext
         if (!HasContext && r is Float1 firstFloat && g is Float1 secondFloat && b is Float1 thirdFloat && a is Float1 fourthFloat)
         {
             Half4 constantHalf4 = new Half4("");
-            byte rByte = (byte)firstFloat.ConstantValue;
-            byte gByte = (byte)secondFloat.ConstantValue;
-            byte bByte = (byte)thirdFloat.ConstantValue;
-            byte aByte = (byte)fourthFloat.ConstantValue;
+            byte rByte = firstFloat.AsConstantColorByte();
+            byte gByte = secondFloat.AsConstantColorByte();
+            byte bByte = thirdFloat.AsConstantColorByte();
+            byte aByte = fourthFloat.AsConstantColorByte();
             constantHalf4.ConstantValue = new Color(rByte, gByte, bByte, aByte);
             return constantHalf4;
         }
@@ -109,6 +110,65 @@ public class FuncContext
         return Builder.ConstructHalf4(r, g, b, a);
     }
 
+    public Half4 HsvaToRgba(Expression h, Expression s, Expression v, Expression a)
+    {
+        if (!HasContext && h is Float1 firstFloat && s is Float1 secondFloat && v is Float1 thirdFloat && a is Float1 fourthFloat)
+        {
+            Half4 constantHalf4 = new Half4("");
+            var hValue = firstFloat.ConstantValue * 360;
+            var sValue = secondFloat.ConstantValue * 100;
+            var vValue = thirdFloat.ConstantValue * 100;
+            byte aByte = fourthFloat.AsConstantColorByte();
+            constantHalf4.ConstantValue = Color.FromHsv((float)hValue, (float)sValue, (float)vValue, aByte);
+            return constantHalf4;
+        }
+
+        return Builder.AssignNewHalf4(Builder.Functions.GetHslToRgb(h, s, v, a));
+    }
+
+    public Half4 HslaToRgba(Expression h, Expression s, Expression l, Expression a)
+    {
+        if (!HasContext && h is Float1 firstFloat && s is Float1 secondFloat && l is Float1 thirdFloat && a is Float1 fourthFloat)
+        {
+            Half4 constantHalf4 = new Half4("");
+            var hValue = firstFloat.ConstantValue * 360;
+            var sValue = secondFloat.ConstantValue * 100;
+            var lValue = thirdFloat.ConstantValue * 100;
+            byte aByte = fourthFloat.AsConstantColorByte();
+            constantHalf4.ConstantValue = Color.FromHsl((float)hValue, (float)sValue, (float)lValue, aByte);
+            return constantHalf4;
+        }
+
+        return Builder.AssignNewHalf4(Builder.Functions.GetHslToRgb(h, s, l, a));
+    }
+    
+    public Half4 RgbaToHsva(Expression color)
+    {
+        if (!HasContext && color is Half4 constantColor)
+        {
+            var variable = new Half4(string.Empty);
+            constantColor.ConstantValue.ToHsv(out float h, out float s, out float l);
+            variable.ConstantValue = new Color((byte)(h * 255), (byte)(s * 255), (byte)(l * 255), constantColor.ConstantValue.A);
+            
+            return variable;
+        }
+
+        return Builder.AssignNewHalf4(Builder.Functions.GetRgbToHsv(color));
+    }
+    
+    public Half4 RgbaToHsla(Expression color)
+    {
+        if (!HasContext && color is Half4 constantColor)
+        {
+            var variable = new Half4(string.Empty);
+            constantColor.ConstantValue.ToHsl(out float h, out float s, out float l);
+            variable.ConstantValue = new Color((byte)(h * 255), (byte)(s * 255), (byte)(l * 255), constantColor.ConstantValue.A);
+            
+            return variable;
+        }
+
+        return Builder.AssignNewHalf4(Builder.Functions.GetRgbToHsl(color));
+    }
 
     public Half4 NewHalf4(Expression assignment)
     {

+ 46 - 10
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineColorNode.cs

@@ -13,11 +13,20 @@ public class CombineColorNode : Node
 
     public InputProperty<CombineSeparateColorMode> Mode { get; }
 
-    public FuncInputProperty<Float1> R { get; }
+    /// <summary>
+    /// Represents either Red 'R' or Hue 'H' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncInputProperty<Float1> V1 { get; }
 
-    public FuncInputProperty<Float1> G { get; }
+    /// <summary>
+    /// Represents either Green 'G' or Saturation 'S' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncInputProperty<Float1> V2 { get; }
 
-    public FuncInputProperty<Float1> B { get; }
+    /// <summary>
+    /// Represents either Blue 'B', Value 'V' or Lightness 'L' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncInputProperty<Float1> V3 { get; }
 
     public FuncInputProperty<Float1> A { get; }
 
@@ -26,22 +35,49 @@ public class CombineColorNode : Node
         Color = CreateFuncOutput(nameof(Color), "COLOR", GetColor);
         Mode = CreateInput("Mode", "MODE", CombineSeparateColorMode.RGB);
 
-        R = CreateFuncInput<Float1>("R", "R", 0d);
-        G = CreateFuncInput<Float1>("G", "G", 0d);
-        B = CreateFuncInput<Float1>("B", "B", 0d);
+        V1 = CreateFuncInput<Float1>("R", "R", 0d);
+        V2 = CreateFuncInput<Float1>("G", "G", 0d);
+        V3 = CreateFuncInput<Float1>("B", "B", 0d);
         A = CreateFuncInput<Float1>("A", "A", 0d);
     }
 
-    private Half4 GetColor(FuncContext ctx)
+    private Half4 GetColor(FuncContext ctx) =>
+        Mode.Value switch
+        {
+            CombineSeparateColorMode.RGB => GetRgb(ctx),
+            CombineSeparateColorMode.HSV => GetHsv(ctx),
+            CombineSeparateColorMode.HSL => GetHsl(ctx)
+        };
+
+    private Half4 GetRgb(FuncContext ctx)
     {
-        var r = ctx.GetValue(R);
-        var g = ctx.GetValue(G);
-        var b = ctx.GetValue(B);
+        var r = ctx.GetValue(V1);
+        var g = ctx.GetValue(V2);
+        var b = ctx.GetValue(V3);
         var a = ctx.GetValue(A);
 
         return ctx.NewHalf4(r, g, b, a); 
     }
 
+    private Half4 GetHsv(FuncContext ctx)
+    {
+        var h = ctx.GetValue(V1);
+        var s = ctx.GetValue(V2);
+        var v = ctx.GetValue(V3);
+        var a = ctx.GetValue(A);
+
+        return ctx.HsvaToRgba(h, s, v, a);
+    }
+    
+    private Half4 GetHsl(FuncContext ctx)
+    {
+        var h = ctx.GetValue(V1);
+        var s = ctx.GetValue(V2);
+        var l = ctx.GetValue(V3);
+        var a = ctx.GetValue(A);
+
+        return ctx.HslaToRgba(h, s, l, a);
+    }
 
     protected override Texture? OnExecute(RenderingContext context)
     {

+ 37 - 26
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateColorNode.cs

@@ -1,4 +1,5 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
+using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.DrawingApi.Core;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
@@ -9,27 +10,37 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
 [NodeInfo("SeparateColor")]
 public class SeparateColorNode : Node
 {
+    private readonly NodeVariableAttachments contextVariables = new();
+    
     public FuncInputProperty<Half4> Color { get; }
     
-    public FuncOutputProperty<Float1> R { get; }
+    public InputProperty<CombineSeparateColorMode> Mode { get; }
+
+    /// <summary>
+    /// Represents either Red 'R' or Hue 'H' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncOutputProperty<Float1> V1 { get; }
     
-    public FuncOutputProperty<Float1> G { get; }
+    /// <summary>
+    /// Represents either Green 'G' or Saturation 'S' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncOutputProperty<Float1> V2 { get; }
     
-    public FuncOutputProperty<Float1> B { get; }
+    /// <summary>
+    /// Represents either Blue 'B', Value 'V' or Lightness 'L' depending on <see cref="Mode"/>
+    /// </summary>
+    public FuncOutputProperty<Float1> V3 { get; }
     
     public FuncOutputProperty<Float1> A { get; }
     
-    
-    private FuncContext lastContext;
-    private Half4 lastColor;
-
     public SeparateColorNode()
     {
+        V1 = CreateFuncOutput<Float1>("R", "R", ctx => GetColor(ctx).R);
+        V2 = CreateFuncOutput<Float1>("G", "G", ctx => GetColor(ctx).G);
+        V3 = CreateFuncOutput<Float1>("B", "B", ctx => GetColor(ctx).B);
+        A = CreateFuncOutput<Float1>("A", "A", ctx => GetColor(ctx).A);
+        Mode = CreateInput("Mode", "MODE", CombineSeparateColorMode.RGB);
         Color = CreateFuncInput<Half4>(nameof(Color), "COLOR", new Color());
-        R = CreateFuncOutput<Float1>(nameof(R), "R", ctx => GetColor(ctx).R);
-        G = CreateFuncOutput<Float1>(nameof(G), "G", ctx => GetColor(ctx).G);
-        B = CreateFuncOutput<Float1>(nameof(B), "B", ctx => GetColor(ctx).B);
-        A = CreateFuncOutput<Float1>(nameof(A), "A", ctx => GetColor(ctx).A);
     }
 
     protected override Texture? OnExecute(RenderingContext context)
@@ -37,22 +48,22 @@ public class SeparateColorNode : Node
         return null;
     }
     
-    private Half4 GetColor(FuncContext ctx)
-    {
-        Half4 target = null;
-        if (lastContext == ctx)
+    private Half4 GetColor(FuncContext ctx) =>
+        Mode.Value switch
         {
-            target = lastColor;
-        }
-        else
-        {
-            target = Color.Value(ctx);
-        }
-        
-        lastColor = target;
-        lastContext = ctx;
-        return lastColor;
-    }
+            CombineSeparateColorMode.RGB => GetRgba(ctx),
+            CombineSeparateColorMode.HSV => GetHsva(ctx),
+            CombineSeparateColorMode.HSL => GetHsla(ctx)
+        };
+
+    private Half4 GetRgba(FuncContext ctx) => 
+        contextVariables.GetOrAttachNew(ctx, Color, () => Color.Value(ctx));
+
+    private Half4 GetHsva(FuncContext ctx) =>
+        contextVariables.GetOrAttachNew(ctx, Color, () => ctx.RgbaToHsva(ctx.GetValue(Color)));
+
+    private Half4 GetHsla(FuncContext ctx) =>
+        contextVariables.GetOrAttachNew(ctx, Color, () => ctx.RgbaToHsla(ctx.GetValue(Color)));
 
     public override Node CreateCopy() => new SeparateColorNode();
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs

@@ -77,7 +77,7 @@ public class ModifyImageRightNode : Node, IPairNode, ICustomShaderNode
         }
         else
         {
-            ShaderBuilder builder = new();
+            ShaderBuilder builder = new(size);
             FuncContext context = new(renderingContext, builder);
 
             if (Coordinate.Connection != null)

+ 46 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NodeVariableAttachments.cs

@@ -0,0 +1,46 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Context;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.DrawingApi.Core.Shaders.Generation;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+internal class NodeVariableAttachments
+{
+    private readonly List<(INodeProperty key, ShaderExpressionVariable variable)> _variables = new();
+    
+    public FuncContext LastContext { get; private set; }
+
+    /// <summary>
+    /// Tries getting a shader variable associated to the property. Otherwise, it creates a new one from the variable factory
+    /// </summary>
+    /// <typeparam name="T">The shader variable type. i.e. Half4, Float1</typeparam>
+    /// <returns>The attached or new variable</returns>
+    public T GetOrAttachNew<T>(FuncContext context, INodeProperty property, Func<T> variable) where T : ShaderExpressionVariable
+    {
+        if (LastContext != context)
+        {
+            LastContext = context;
+            _variables.Clear();
+            
+            return AttachNew(property, variable);
+        }
+
+        var existing = _variables.FirstOrDefault(x => x.key == property);
+
+        if (existing == default)
+        {
+            existing.variable = AttachNew(property, variable);
+        }
+
+        return (T)existing.variable;
+    }
+
+    private T AttachNew<T>(INodeProperty property, Func<T> variable) where T : ShaderExpressionVariable
+    {
+        var newVariable = variable();
+        
+        _variables.Add((property, newVariable));
+
+        return newVariable;
+    }
+}

+ 149 - 0
src/PixiEditor.DrawingApi.Core/Shaders/Generation/BuiltInFunctions.cs

@@ -0,0 +1,149 @@
+using System.Collections.Generic;
+using System.Text;
+using PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
+
+namespace PixiEditor.DrawingApi.Core.Shaders.Generation;
+
+public class BuiltInFunctions
+{
+    private readonly List<IBuiltInFunction> usedFunctions = new(6);
+
+    public Expression GetRgbToHsv(Expression rgba) => Call(RgbToHsv, rgba);
+
+    public Expression GetRgbToHsl(Expression rgba) => Call(RgbToHsl, rgba);
+
+    public Expression GetHsvToRgb(Expression hsva) => Call(HsvToRgb, hsva);
+
+    public Expression GetHsvToRgb(Expression h, Expression s, Expression v, Expression a) =>
+        GetHsvToRgb(Half4Float1Accessor.GetOrConstructorExpressionHalf4(h, s, v, a));
+
+    public Expression GetHslToRgb(Expression hsla) => Call(HslToRgb, hsla);
+
+    public Expression GetHslToRgb(Expression h, Expression s, Expression l, Expression a) =>
+        GetHslToRgb(Half4Float1Accessor.GetOrConstructorExpressionHalf4(h, s, l, a));
+
+    public string BuildFunctions()
+    {
+        var builder = new StringBuilder();
+
+        foreach (var function in usedFunctions)
+        {
+            builder.AppendLine(function.FullSource);
+        }
+
+        return builder.ToString();
+    }
+
+    private Expression Call(IBuiltInFunction function, Expression expression)
+    {
+        Require(function);
+
+        return new Expression(function.Call(expression.ExpressionValue));
+    }
+
+    private void Require(IBuiltInFunction function)
+    {
+        if (usedFunctions.Contains(function))
+        {
+            return;
+        }
+
+        foreach (var dependency in function.Dependencies)
+        {
+            Require(dependency);
+        }
+
+        usedFunctions.Add(function);
+    }
+
+    // Taken from here https://www.shadertoy.com/view/4dKcWK
+    private static readonly BuiltInFunction<Half3> HueToRgb = new(
+        "float hue",
+        nameof(HueToRgb),
+        """
+        half3 rgb = abs(hue * 6. - half3(3, 2, 4)) * half3(1, -1, -1) + half3(-1, 2, 2);
+        return clamp(rgb, 0., 1.);
+        """);
+
+    private static readonly BuiltInFunction<Half3> RgbToHcv = new(
+        "half3 rgba",
+        nameof(RgbToHcv),
+        """
+        half4 p = (rgba.g < rgba.b) ? half4(rgba.bg, -1., 2. / 3.) : half4(rgba.gb, 0., -1. / 3.);
+        half4 q = (rgba.r < p.x) ? half4(p.xyw, rgba.r) : half4(rgba.r, p.yzx);
+        float c = q.x - min(q.w, q.y);
+        float h = abs((q.w - q.y) / (6. * c) + q.z);
+        return half3(h, c, q.x);
+        """);
+
+    private static readonly BuiltInFunction<Half4> RgbToHsv = new(
+        "half4 rgba",
+        nameof(RgbToHsv),
+        $"""
+         half3 hcv = {RgbToHcv.Call("rgba.rgb")};
+         float s = hcv.y / (hcv.z);
+         return half4(hcv.x, s, hcv.z, rgba.w);
+         """,
+        RgbToHcv);
+
+    private static readonly BuiltInFunction<Half4> HsvToRgb = new(
+        "half4 hsva",
+        nameof(HsvToRgb),
+        $"""
+         half3 rgb = {HueToRgb.Call("hsva.r")};
+         return half4(((rgb - 1.) * hsva.y + 1.) * hsva.z, hsva.w);
+         """,
+        HueToRgb);
+
+    private static readonly BuiltInFunction<Half4> RgbToHsl = new(
+        "half4 rgba", 
+        nameof(RgbToHsl), 
+        $"""
+         half3 hcv = {RgbToHcv.Call("rgba.rgb")};
+         half z = hcv.z - hcv.y * 0.5;
+         half s = hcv.y / (1. - abs(z * 2. - 1.));
+         return half4(hcv.x, s, z, rgba.w);
+         """,
+        RgbToHcv);
+
+    private static readonly BuiltInFunction<Half4> HslToRgb = new(
+        "half4 hsla",
+        nameof(HslToRgb),
+        $"""
+         half3 rgb = {HueToRgb.Call("hsla.r")};
+         float c = (1. - abs(2. * hsla.z - 1.)) * hsla.y;
+         return half4((rgb - 0.5) * c + hsla.z, hsla.w);
+         """,
+        HueToRgb);
+
+    private class BuiltInFunction<TReturn>(string argumentList, string name, string body, params IBuiltInFunction[] dependencies) : IBuiltInFunction where TReturn : ShaderExpressionVariable
+    {
+        public string ArgumentList { get; } = argumentList;
+
+        public string Name { get; } = name;
+
+        public string Body { get; } = body;
+
+        public IBuiltInFunction[] Dependencies { get; } = dependencies;
+
+        public string FullSource =>
+         $$"""
+          {{typeof(TReturn).Name.ToLower()}} {{Name}}({{ArgumentList}}) {
+          {{Body}}
+          }
+          """;
+
+        public string Call(string arguments) => $"{Name}({arguments})";
+    }
+    
+    private interface IBuiltInFunction
+    {
+        IBuiltInFunction[] Dependencies { get; }
+        
+        string Name { get; }
+        
+        string FullSource { get; }
+
+        string Call(string arguments);
+    }
+}

+ 6 - 3
src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Float1.cs

@@ -1,4 +1,6 @@
-namespace PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
+using System;
+
+namespace PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
 
 /// <summary>
 ///     This is a shader type that represents a high precision floating point value. For medium precision see Short type.
@@ -12,8 +14,9 @@ public class Float1(string name) : ShaderExpressionVariable<double>(name)
 
     public override Expression? OverrideExpression { get; set; }
 
-    public static implicit operator Float1(double value) => new Float1("") { ConstantValue = value };
+    public static implicit operator Float1(double value) => new("") { ConstantValue = value };
 
     public static explicit operator double(Float1 value) => value.ConstantValue;
-    
+
+    public byte AsConstantColorByte() => (byte)(Math.Clamp(ConstantValue, 0, 1) * 255);
 }

+ 39 - 0
src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half3.cs

@@ -0,0 +1,39 @@
+using System;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
+
+public class Half3(string name) : ShaderExpressionVariable<VecD3>(name), IMultiValueVariable
+{
+    private Expression? _overrideExpression;
+    public override string ConstantValueString => $"half3({ConstantValue.X}, {ConstantValue.Y}, {ConstantValue.Z})";
+    
+    public Float1 R => new Half3Float1Accessor(this, 'r') { ConstantValue = ConstantValue.X, OverrideExpression = _overrideExpression};
+    public Float1 G => new Half3Float1Accessor(this, 'g') { ConstantValue = ConstantValue.X, OverrideExpression = _overrideExpression};
+    public Float1 B => new Half3Float1Accessor(this, 'b') { ConstantValue = ConstantValue.Z, OverrideExpression = _overrideExpression};
+
+    public override Expression? OverrideExpression
+    {
+        get => _overrideExpression;
+        set
+        {
+            _overrideExpression = value;
+        }
+    }
+
+    public ShaderExpressionVariable GetValueAt(int index)
+    {
+        return index switch
+        {
+            0 => R,
+            1 => G,
+            2 => B,
+            _ => throw new IndexOutOfRangeException()
+        };
+    }
+
+    public static string ConstructorText(Expression r, Expression g, Expression b) =>
+        $"half3({r.ExpressionValue}, {g.ExpressionValue}, {b.ExpressionValue})";
+
+    public static Expression Constructor(Expression r, Expression g, Expression b) => new(ConstructorText(r, g, b));
+}

+ 26 - 0
src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half3Float1Accessor.cs

@@ -0,0 +1,26 @@
+namespace PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
+
+public class Half3Float1Accessor : Float1
+{
+    public Half3Float1Accessor(Half3 accessTo, char name) : base(string.IsNullOrEmpty(accessTo.VariableName) ? string.Empty : $"{accessTo.VariableName}.{name}")
+    {
+        Accesses = accessTo;
+    }
+    
+    public Half3 Accesses { get; }
+
+    public static bool AllAccessSame(Expression r, Expression g, Expression b, out Half3? half3)
+    {
+        if (r is Half3Float1Accessor rA && g is Half3Float1Accessor gA && b is Half3Float1Accessor bA &&
+            rA.Accesses == gA.Accesses && rA.Accesses == bA.Accesses)
+        {
+            half3 = rA.Accesses;
+            return true;
+        }
+
+        half3 = null;
+        return false;
+    }
+    
+    public static Expression GetOrConstructorExpressionHalf3(Expression r, Expression g, Expression b) => AllAccessSame(r, g, b, out var value) ? value : Half3.Constructor(r, g, b);
+}

+ 12 - 6
src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half4.cs

@@ -8,12 +8,12 @@ public class Half4(string name) : ShaderExpressionVariable<Color>(name), IMultiV
     private Expression? _overrideExpression;
     public override string ConstantValueString => $"half4({ConstantValue.R}, {ConstantValue.G}, {ConstantValue.B}, {ConstantValue.A})";
     
-    public Float1 R => new Float1(string.IsNullOrEmpty(VariableName) ? string.Empty : $"{VariableName}.r") { ConstantValue = ConstantValue.R, OverrideExpression = _overrideExpression};
-    public Float1 G => new Float1(string.IsNullOrEmpty(VariableName) ? string.Empty : $"{VariableName}.g") { ConstantValue = ConstantValue.G, OverrideExpression = _overrideExpression};
-    public Float1 B => new Float1(string.IsNullOrEmpty(VariableName) ? string.Empty : $"{VariableName}.b") { ConstantValue = ConstantValue.B, OverrideExpression = _overrideExpression};
-    public Float1 A => new Float1(string.IsNullOrEmpty(VariableName) ? string.Empty : $"{VariableName}.a") { ConstantValue = ConstantValue.A, OverrideExpression = _overrideExpression};
-    
-    public static implicit operator Half4(Color value) => new Half4("") { ConstantValue = value };
+    public Float1 R => new Half4Float1Accessor(this, 'r') { ConstantValue = ConstantValue.R, OverrideExpression = _overrideExpression};
+    public Float1 G => new Half4Float1Accessor(this, 'g') { ConstantValue = ConstantValue.G, OverrideExpression = _overrideExpression};
+    public Float1 B => new Half4Float1Accessor(this, 'b') { ConstantValue = ConstantValue.B, OverrideExpression = _overrideExpression};
+    public Float1 A => new Half4Float1Accessor(this, 'a') { ConstantValue = ConstantValue.A, OverrideExpression = _overrideExpression};
+
+    public static implicit operator Half4(Color value) => new("") { ConstantValue = value };
     public static explicit operator Color(Half4 value) => value.ConstantValue;
 
     public override Expression? OverrideExpression
@@ -36,4 +36,10 @@ public class Half4(string name) : ShaderExpressionVariable<Color>(name), IMultiV
             _ => throw new IndexOutOfRangeException()
         };
     }
+
+    public static string ConstructorText(Expression r, Expression g, Expression b, Expression a) =>
+        $"half4({r.ExpressionValue}, {g.ExpressionValue}, {b.ExpressionValue}, {a.ExpressionValue})";
+
+    public static Expression Constructor(Expression r, Expression g, Expression b, Expression a) =>
+        new Expression(ConstructorText(r, g, b, a));
 }

+ 27 - 0
src/PixiEditor.DrawingApi.Core/Shaders/Generation/Expressions/Half4Float1Accessor.cs

@@ -0,0 +1,27 @@
+namespace PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
+
+public class Half4Float1Accessor : Float1
+{
+    public Half4Float1Accessor(Half4 accessTo, char name) : base(string.IsNullOrEmpty(accessTo.VariableName) ? string.Empty : $"{accessTo.VariableName}.{name}")
+    {
+        Accesses = accessTo;
+    }
+    
+    public Half4 Accesses { get; }
+
+    public static bool AllAccessSame(Expression r, Expression g, Expression b, Expression a, out Half4? half4)
+    {
+        if (r is Half4Float1Accessor rA && g is Half4Float1Accessor gA &&
+            b is Half4Float1Accessor bA && a is Half4Float1Accessor aA &&
+            rA.Accesses == gA.Accesses && bA.Accesses == aA.Accesses && rA.Accesses == bA.Accesses)
+        {
+            half4 = rA.Accesses;
+            return true;
+        }
+
+        half4 = null;
+        return false;
+    }
+
+    public static Expression GetOrConstructorExpressionHalf4(Expression r, Expression g, Expression b, Expression a) => AllAccessSame(r, g, b, a, out var value) ? value : Half4.Constructor(r, g, b, a);
+}

+ 19 - 10
src/PixiEditor.DrawingApi.Core/Shaders/Generation/ShaderBuilder.cs

@@ -1,4 +1,6 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Linq;
 using System.Text;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Shaders.Generation.Expressions;
@@ -16,6 +18,13 @@ public class ShaderBuilder
 
     private Dictionary<Texture, TextureSampler> _samplers = new Dictionary<Texture, TextureSampler>();
 
+    public BuiltInFunctions Functions { get; } = new();
+
+    public ShaderBuilder(VecI resolution)
+    {
+        AddUniform("iResolution", resolution);
+    }
+    
     public Shader BuildShader()
     {
         string generatedSksl = ToSkSl();
@@ -26,8 +35,12 @@ public class ShaderBuilder
     {
         StringBuilder sb = new StringBuilder();
         AppendUniforms(sb);
+
+        sb.AppendLine(Functions.BuildFunctions());
+        
         sb.AppendLine("half4 main(float2 coords)");
         sb.AppendLine("{");
+        sb.AppendLine("coords = coords / iResolution;");
         sb.Append(_bodyBuilder);
         sb.AppendLine("}");
 
@@ -64,7 +77,7 @@ public class ShaderBuilder
         string resultName = $"color_{GetUniqueNameNumber()}";
         Half4 result = new Half4(resultName);
         _variables.Add(result);
-        _bodyBuilder.AppendLine($"half4 {resultName} = sample({texName.VariableName}, {pos.VariableName});");
+        _bodyBuilder.AppendLine($"half4 {resultName} = sample({texName.VariableName}, {pos.VariableName} * iResolution);");
         return result;
     }
 
@@ -152,19 +165,15 @@ public class ShaderBuilder
         Half4 result = new Half4(name);
         _variables.Add(result);
 
-        string rExpression = r.ExpressionValue;
-        string gExpression = g.ExpressionValue;
-        string bExpression = b.ExpressionValue;
-        string aExpression = a.ExpressionValue;
-
-        _bodyBuilder.AppendLine($"half4 {name} = half4({rExpression}, {gExpression}, {bExpression}, {aExpression});");
+        _bodyBuilder.AppendLine($"half4 {name} = {Half4.ConstructorText(r, g, b, a)};");
         return result;
     }
 
 
-    public Half4 AssignNewHalf4(Expression assignment)
+    public Half4 AssignNewHalf4(Expression assignment) => AssignNewHalf4($"color_{GetUniqueNameNumber()}", assignment);
+
+    public Half4 AssignNewHalf4(string name, Expression assignment)
     {
-        string name = $"color_{GetUniqueNameNumber()}";
         Half4 result = new Half4(name);
         _variables.Add(result);
 

+ 43 - 0
src/PixiEditor.Linux/LinuxOperatingSystem.cs

@@ -1,4 +1,5 @@
 using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
 using Avalonia.Threading;
 using PixiEditor.OperatingSystem;
 
@@ -7,6 +8,8 @@ namespace PixiEditor.Linux;
 public sealed class LinuxOperatingSystem : IOperatingSystem
 {
     public string Name { get; } = "Linux";
+    public string AnalyticsId => "Linux";
+    public string AnalyticsName => LinuxOSInformation.FromReleaseFile().ToString();
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
     
@@ -24,4 +27,44 @@ public sealed class LinuxOperatingSystem : IOperatingSystem
     {
         return true;
     }
+
+    class LinuxOSInformation
+    {
+        const string FilePath = "/etc/os-release";
+        
+        private LinuxOSInformation(string? name, string? version, bool available)
+        {
+            Name = name;
+            Version = version;
+            Available = available;
+        }
+
+        public static LinuxOSInformation FromReleaseFile()
+        {
+            if (!File.Exists(FilePath))
+            {
+                return new LinuxOSInformation(null, null, false);
+            }
+            
+            // Parse /etc/os-release file (e.g. 'NAME="Ubuntu"')
+            var lines = File.ReadAllLines(FilePath).Select<string, (string Key, string Value)>(x =>
+            {
+                var separatorIndex = x.IndexOf('=');
+                return (x[..separatorIndex], x[(separatorIndex + 1)..]);
+            }).ToList();
+            
+            var name = lines.FirstOrDefault(x => x.Key == "NAME").Value.Trim('"');
+            var version = lines.FirstOrDefault(x => x.Key == "VERSION").Value.Trim('"');
+            
+            return new LinuxOSInformation(name, version, true);
+        }
+        
+        public bool Available { get; }
+        
+        public string? Name { get; private set; }
+        
+        public string? Version { get; private set; }
+
+        public override string ToString() => $"{Name} {Version}";
+    }
 }

+ 3 - 0
src/PixiEditor.MacOs/MacOperatingSystem.cs

@@ -7,6 +7,9 @@ namespace PixiEditor.MacOs;
 public sealed class MacOperatingSystem : IOperatingSystem
 {
     public string Name { get; } = "MacOS";
+
+    public string AnalyticsId => "macOS";
+    
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
     public void OpenUri(string uri)

+ 3 - 0
src/PixiEditor.OperatingSystem/IOperatingSystem.cs

@@ -8,6 +8,9 @@ public interface IOperatingSystem
     public static IOperatingSystem Current { get; protected set; }
     public string Name { get; }
 
+    public virtual string AnalyticsName => Environment.OSVersion.ToString();
+    public string AnalyticsId { get; }
+
     public IInputKeys InputKeys { get; }
     public IProcessUtility ProcessUtility { get; }
 

+ 3 - 0
src/PixiEditor.Windows/WindowsOperatingSystem.cs

@@ -9,6 +9,9 @@ namespace PixiEditor.Windows;
 public sealed class WindowsOperatingSystem : IOperatingSystem
 {
     public string Name => "Windows";
+    
+    public string AnalyticsId => "Windows";
+    
     public IInputKeys InputKeys { get; } = new WindowsInputKeys();
     public IProcessUtility ProcessUtility { get; } = new WindowsProcessUtility();
     

BIN
src/PixiEditor/Data/BetaExampleFiles/Island.pixi


BIN
src/PixiEditor/Data/BetaExampleFiles/Pond.pixi


BIN
src/PixiEditor/Data/BetaExampleFiles/Stars.pixi


BIN
src/PixiEditor/Data/BetaExampleFiles/Tree.pixi


+ 7 - 0
src/PixiEditor/Exceptions/CommandInvocationException.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Exceptions;
+
+public class CommandInvocationException : Exception
+{
+    public CommandInvocationException(string commandName, Exception? innerException = null) : 
+        base($"Command '{commandName}' threw an exception", innerException) { }
+}

+ 12 - 3
src/PixiEditor/Helpers/CrashHelper.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.Net.Http;
 using System.Runtime.CompilerServices;
 using System.Text;
+using System.Text.RegularExpressions;
 using System.Threading.Tasks;
 using ByteSizeLib;
 using Hardware.Info;
@@ -12,7 +13,7 @@ using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Helpers;
 
-internal class CrashHelper
+internal partial class CrashHelper
 {
     private readonly IHardwareInfo hwInfo;
 
@@ -84,7 +85,7 @@ internal class CrashHelper
             .AppendLine("\n-------Crash message-------")
             .Append(e.GetType().ToString())
             .Append(": ")
-            .AppendLine(e.Message);
+            .AppendLine(TrimFilePaths(e.Message));
         {
             var innerException = e.InnerException;
             while (innerException != null)
@@ -93,7 +94,7 @@ internal class CrashHelper
                     .Append("\n-----Inner exception-----\n")
                     .Append(innerException.GetType().ToString())
                     .Append(": ")
-                    .Append(innerException.Message);
+                    .Append(TrimFilePaths(innerException.Message));
                 innerException = innerException.InnerException;
             }
         }
@@ -112,6 +113,8 @@ internal class CrashHelper
             }
         }
     }
+
+    private static string TrimFilePaths(string text) => FilePathRegex().Replace(text, "{{ FILE PATH }}");
     
     public static void SendExceptionInfoToWebhook(Exception e, bool wait = false,
         [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
@@ -156,4 +159,10 @@ internal class CrashHelper
         }
         catch { }
     }
+
+    /// <summary>
+    /// Matches file paths with spaces when in quotes, otherwise not
+    /// </summary>
+    [GeneratedRegex(@"'([^']*[\/\\][^']*)'|(\S*[\/\\]\S*)")]
+    private static partial Regex FilePathRegex();
 }

+ 13 - 0
src/PixiEditor/Helpers/Extensions/CombineSeparateColorModeExtensions.cs

@@ -0,0 +1,13 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
+
+namespace PixiEditor.Helpers.Extensions;
+
+internal static class CombineSeparateColorModeExtensions
+{
+    public static (string v1, string v2, string v3) GetLocalizedColorStringNames(this CombineSeparateColorMode mode) => mode switch
+    {
+        CombineSeparateColorMode.RGB => ("R", "G", "B"),
+        CombineSeparateColorMode.HSV => ("H", "S", "V"),
+        CombineSeparateColorMode.HSL => ("H", "S", "L"),
+    };
+}

+ 1 - 1
src/PixiEditor/Helpers/VersionHelpers.cs

@@ -45,7 +45,7 @@ internal static class VersionHelpers
 #elif RELEASE
         return "ClosedBetaRelease";
 #elif STEAM
-        return "ClosedBetaStream";
+        return "ClosedBetaSteam";
 #elif MSIX
         return "ClosedBetaMSIX";
 #else

+ 1 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticEventTypes.cs

@@ -12,6 +12,7 @@ public class AnalyticEventTypes
     public static string GeneralCommand { get; } = GetEventType("GeneralCommand");
     public static string SwitchTool { get; } = GetEventType("SwitchTool");
     public static string UseTool { get; } = GetEventType("UseTool");
+    public static string ResumeSession { get; } = GetEventType("ResumeSession");
 
     private static string GetEventType(string value) => $"PixiEditor.{value}";
 }

+ 8 - 2
src/PixiEditor/Models/AnalyticsAPI/AnalyticSessionInfo.cs

@@ -1,8 +1,14 @@
-namespace PixiEditor.Models.AnalyticsAPI;
+using PixiEditor.OperatingSystem;
 
-public class AnalyticSessionInfo
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticSessionInfo(IOperatingSystem os)
 {
     public Version Version { get; set; }
 
     public string BuildId { get; set; }
+
+    public string? PlatformId { get; set; } = os.AnalyticsId;
+
+    public string? PlatformName { get; set; } = os.AnalyticsName;
 }

+ 4 - 2
src/PixiEditor/Models/AnalyticsAPI/AnalyticsClient.cs

@@ -6,6 +6,7 @@ using System.Text.Json.Serialization;
 using PixiEditor.Helpers;
 using PixiEditor.Models.Input;
 using PixiEditor.Numerics;
+using PixiEditor.OperatingSystem;
 
 namespace PixiEditor.Models.AnalyticsAPI;
 
@@ -31,9 +32,10 @@ public class AnalyticsClient
 
     public async Task<Guid?> CreateSessionAsync(CancellationToken cancellationToken = default)
     {
-        var session = new AnalyticSessionInfo()
+        var session = new AnalyticSessionInfo(IOperatingSystem.Current)
         {
-            Version = VersionHelpers.GetCurrentAssemblyVersion(), BuildId = VersionHelpers.GetBuildId()
+            Version = VersionHelpers.GetCurrentAssemblyVersion(),
+            BuildId = VersionHelpers.GetBuildId(),
         };
         
         var response = await _client.PostAsJsonAsync("sessions/new", session, _options, cancellationToken);

+ 25 - 7
src/PixiEditor/Models/AnalyticsAPI/AnalyticsPeriodicReporter.cs

@@ -5,6 +5,7 @@ namespace PixiEditor.Models.AnalyticsAPI;
 public class AnalyticsPeriodicReporter
 {
     private int _sendExceptions = 0;
+    private bool _resumeSession;
     
     private readonly SemaphoreSlim _semaphore = new(1, 1);
     private readonly AnalyticsClient _client;
@@ -28,8 +29,16 @@ public class AnalyticsPeriodicReporter
         _client = client;
     }
 
-    public void Start()
+    public void Start(Guid? sessionId)
     {
+        if (sessionId != null)
+        {
+            SessionId = sessionId.Value;
+            _resumeSession = true;
+            
+            _backlog.Add(new AnalyticEvent { Time = DateTime.UtcNow, EventType = AnalyticEventTypes.ResumeSession });
+        }
+
         Task.Run(RunAsync);
     }
 
@@ -42,6 +51,12 @@ public class AnalyticsPeriodicReporter
 
     public void AddEvent(AnalyticEvent value)
     {
+        // Don't send startup as it gives invalid results for crash resumed sessions
+        if (value.EventType == AnalyticEventTypes.Startup && _resumeSession)
+        {
+            return;
+        }
+        
         Task.Run(() =>
         {
             _semaphore.Wait();
@@ -59,14 +74,17 @@ public class AnalyticsPeriodicReporter
 
     private async Task RunAsync()
     {
-        var createSession = await _client.CreateSessionAsync(_cancellationToken.Token);
-
-        if (!createSession.HasValue)
+        if (!_resumeSession)
         {
-            return;
-        }
+            var createSession = await _client.CreateSessionAsync(_cancellationToken.Token);
+
+            if (!createSession.HasValue)
+            {
+                return;
+            }
 
-        SessionId = createSession.Value;
+            SessionId = createSession.Value;
+        }
 
         Task.Run(RunHeartbeatAsync);
 

+ 12 - 4
src/PixiEditor/Models/Commands/CommandController.cs

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
 using Avalonia.Media;
 using Microsoft.Extensions.DependencyInjection;
 using Newtonsoft.Json;
+using PixiEditor.Exceptions;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Models.AnalyticsAPI;
@@ -376,10 +377,17 @@ internal class CommandController
         {
             Analytics.SendCommand(name, (parameter as CommandExecutionContext)?.SourceInfo);
         }
-                
-        object result = method.Invoke(instance, parameters);
-        if (result is Task task)
-            task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+
+        try
+        {
+            object result = method.Invoke(instance, parameters);
+            if (result is Task task)
+                task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+        }
+        catch (TargetInvocationException e)
+        {
+            throw new CommandInvocationException(name, e);
+        }
 
         return;
 

+ 1 - 1
src/PixiEditor/Models/Events/NodePropertyValueChanged.cs

@@ -4,4 +4,4 @@ namespace PixiEditor.Models.Events;
 
 public delegate void NodePropertyValueChanged(INodePropertyHandler property, NodePropertyValueChangedArgs args);
 
-public record NodePropertyValueChangedArgs(object OldValue, object NewValue);
+public record NodePropertyValueChangedArgs(object? OldValue, object? NewValue);

+ 15 - 11
src/PixiEditor/Models/ExceptionHandling/CrashReport.cs

@@ -12,6 +12,7 @@ using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Helpers;
+using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels;
@@ -115,6 +116,8 @@ internal class CrashReport : IDisposable
         builder
             .AppendLine("Environment:")
             .AppendLine($"  Thread Count: {GetFormatted(() => Process.GetCurrentProcess().Threads.Count)}")
+            .AppendLine("Analytics:")
+            .AppendLine($"  Analytics Id: {GetFormatted(() => AnalyticsPeriodicReporter.Instance?.SessionId)}")
             .AppendLine("\nCulture:")
             .AppendLine($"  Selected language: {GetPreferenceFormatted("LanguageCode", true, "system")}")
             .AppendLine($"  Current Culture: {GetFormatted(() => CultureInfo.CurrentCulture)}")
@@ -267,15 +270,16 @@ internal class CrashReport : IDisposable
 
     public int GetDocumentCount() => ZipFile.Entries.Where(x => x.FullName.EndsWith(".pixi")).Count();
 
-    public bool TryRecoverDocuments(out List<RecoveredPixi> list)
+    public bool TryRecoverDocuments(out List<RecoveredPixi> list, out CrashedSessionInfo? sessionInfo)
     {
         try
         {
-            list = RecoverDocuments();
+            list = RecoverDocuments(out sessionInfo);
         }
         catch (Exception e)
         {
             list = null;
+            sessionInfo = null;
             CrashHelper.SendExceptionInfoToWebhook(e);
             return false;
         }
@@ -283,12 +287,12 @@ internal class CrashReport : IDisposable
         return true;
     }
 
-    public List<RecoveredPixi> RecoverDocuments()
+    public List<RecoveredPixi> RecoverDocuments(out CrashedSessionInfo? sessionInfo)
     {
         List<RecoveredPixi> recoveredDocuments = new();
 
-        var paths = TryGetOriginalPaths();
-        if (paths == null)
+        sessionInfo = TryGetSessionInfo();
+        if (sessionInfo?.OpenedDocuments == null)
         {
             recoveredDocuments.AddRange(
                 ZipFile.Entries
@@ -300,11 +304,11 @@ internal class CrashReport : IDisposable
             return recoveredDocuments;
         }
 
-        recoveredDocuments.AddRange(paths.Select(path => new RecoveredPixi(path.Value, ZipFile.GetEntry($"Documents/{path.Key}"))));
+        recoveredDocuments.AddRange(sessionInfo.OpenedDocuments.Select(path => new RecoveredPixi(path.OriginalPath, ZipFile.GetEntry($"Documents/{path.ZipName}"))));
 
         return recoveredDocuments;
 
-        Dictionary<string, string>? TryGetOriginalPaths()
+        CrashedSessionInfo? TryGetSessionInfo()
         {
             var originalPathsEntry = ZipFile.Entries.FirstOrDefault(entry => entry.FullName == "DocumentInfo.json");
 
@@ -317,7 +321,7 @@ internal class CrashReport : IDisposable
                 using var reader = new StreamReader(stream);
                 string json = reader.ReadToEnd();
 
-                return JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
+                return JsonConvert.DeserializeObject<CrashedSessionInfo>(json);
             }
             catch
             {
@@ -373,7 +377,7 @@ internal class CrashReport : IDisposable
 
         // Write the documents into zip
         int counter = 0;
-        var originalPaths = new Dictionary<string, string>();
+        var originalPaths = new List<CrashedFileInfo>();
         //TODO: Implement
         foreach (var document in documents)
         {
@@ -389,7 +393,7 @@ internal class CrashReport : IDisposable
                 using Stream documentStream = archive.CreateEntry($"Documents/{nameInZip}").Open();
                 documentStream.Write(serialized);
 
-                originalPaths.Add(nameInZip, document.FullFilePath);
+                originalPaths.Add(new CrashedFileInfo(nameInZip, document.FullFilePath));
             }
             catch { }
             counter++;
@@ -400,7 +404,7 @@ internal class CrashReport : IDisposable
             using Stream jsonStream = archive.CreateEntry("DocumentInfo.json").Open();
             using StreamWriter writer = new StreamWriter(jsonStream);
 
-            string originalPathsJson = JsonConvert.SerializeObject(originalPaths, Formatting.Indented);
+            string originalPathsJson = JsonConvert.SerializeObject(new CrashedSessionInfo(AnalyticsPeriodicReporter.Instance?.SessionId ?? Guid.Empty, originalPaths), Formatting.Indented);
             writer.Write(originalPathsJson);
         }
     }

+ 16 - 0
src/PixiEditor/Models/ExceptionHandling/CrashedFileInfo.cs

@@ -0,0 +1,16 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class CrashedFileInfo
+{
+    public string ZipName { get; set; }
+    
+    public string OriginalPath { get; set; }
+    
+    public CrashedFileInfo() { }
+
+    public CrashedFileInfo(string zipName, string originalPath)
+    {
+        ZipName = zipName;
+        OriginalPath = originalPath;
+    }
+}

+ 18 - 0
src/PixiEditor/Models/ExceptionHandling/CrashedSessionInfo.cs

@@ -0,0 +1,18 @@
+namespace PixiEditor.Models.ExceptionHandling;
+
+public class CrashedSessionInfo
+{
+    public Guid? AnalyticsSessionId { get; set; }
+    
+    public ICollection<CrashedFileInfo>? OpenedDocuments { get; set; }
+
+    public CrashedSessionInfo()
+    {
+    }
+
+    public CrashedSessionInfo(Guid? analyticsSessionId, ICollection<CrashedFileInfo> openedDocuments)
+    {
+        AnalyticsSessionId = analyticsSessionId;
+        OpenedDocuments = openedDocuments;
+    }
+}

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

@@ -42,5 +42,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("2.0.0.18")]
-[assembly: AssemblyFileVersion("2.0.0.18")]
+[assembly: AssemblyVersion("2.0.0.19")]
+[assembly: AssemblyFileVersion("2.0.0.19")]

+ 5 - 1
src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/CombineColorNodeViewModel.cs

@@ -1,7 +1,11 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Nodes.Properties;
 
 namespace PixiEditor.ViewModels.Document.Nodes.CombineSeparate;
 
 [NodeViewModel("COMBINE_COLOR_NODE", "COLOR", "\ue908")]
-internal class CombineColorNodeViewModel : NodeViewModel<CombineColorNode>;
+internal class CombineColorNodeViewModel() : CombineSeparateColorNodeViewModel<CombineColorNode>(true);

+ 53 - 0
src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/CombineSeparateColorNodeViewModel.cs

@@ -0,0 +1,53 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Handlers;
+using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Nodes.Properties;
+
+namespace PixiEditor.ViewModels.Document.Nodes.CombineSeparate;
+
+internal abstract class CombineSeparateColorNodeViewModel<T>(bool isInput) : NodeViewModel<T> where T : Node
+{
+    private GenericEnumPropertyViewModel Mode { get; set; }
+    
+    private NodePropertyViewModel<double> V1 { get; set; }
+
+    private NodePropertyViewModel<double> V2 { get; set; }
+    
+    private NodePropertyViewModel<double> V3 { get; set; }
+
+    public override void OnInitialized()
+    {
+        Mode = FindInputProperty("Mode") as GenericEnumPropertyViewModel;
+        
+        Mode.ValueChanged += OnModeChanged;
+
+        if (isInput)
+        {
+            V1 = FindInputProperty<double>("R");
+            V2 = FindInputProperty<double>("G");
+            V3 = FindInputProperty<double>("B");
+        }
+        else
+        {
+            V1 = FindOutputProperty<double>("R");
+            V2 = FindOutputProperty<double>("G");
+            V3 = FindOutputProperty<double>("B");
+        }
+    }
+
+    private void OnModeChanged(INodePropertyHandler property, NodePropertyValueChangedArgs args)
+    {
+        if (args.NewValue == null) return;
+        
+        var mode = (CombineSeparateColorMode)args.NewValue;
+
+        var (v1, v2, v3) = mode.GetLocalizedColorStringNames();
+
+        V1.DisplayName = v1;
+        V2.DisplayName = v2;
+        V3.DisplayName = v3;
+    }
+}

+ 1 - 1
src/PixiEditor/ViewModels/Document/Nodes/CombineSeparate/SeparateColorNodeViewModel.cs

@@ -4,4 +4,4 @@ using PixiEditor.ViewModels.Nodes;
 namespace PixiEditor.ViewModels.Document.Nodes.CombineSeparate;
 
 [NodeViewModel("SEPARATE_COLOR_NODE", "COLOR", "\ue913")]
-internal class SeparateColorNodeViewModel : NodeViewModel<SeparateColorNode>;
+internal class SeparateColorNodeViewModel() : CombineSeparateColorNodeViewModel<SeparateColorNode>(false);

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -272,7 +272,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
 
         AddRecentlyOpened(path);
 
-        var fileType = SupportedFilesHelper.ParseImageFormat(path);
+        var fileType = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(path));
 
         if (fileType != null)
         {

+ 9 - 9
src/PixiEditor/Views/MainWindow.axaml.cs

@@ -55,7 +55,7 @@ internal partial class MainWindow : Window
         }
     }
 
-    public MainWindow(ExtensionLoader extensionLoader)
+    public MainWindow(ExtensionLoader extensionLoader, Guid? analyticsSessionId = null)
     {
         StartupPerformance.ReportToMainWindow();
         
@@ -83,7 +83,7 @@ internal partial class MainWindow : Window
         StartupPerformance.ReportToMainViewModel();
 
         var analytics = services.GetService<AnalyticsPeriodicReporter>();
-        analytics?.Start();
+        analytics?.Start(analyticsSessionId);
         
         InitializeComponent();
     }
@@ -115,15 +115,15 @@ internal partial class MainWindow : Window
 
     public static MainWindow CreateWithRecoveredDocuments(CrashReport report, out bool showMissingFilesDialog)
     {
-        var window = GetMainWindow();
-        var fileVM = window.services.GetRequiredService<FileViewModel>();
-
-        if (!report.TryRecoverDocuments(out var documents))
+        if (!report.TryRecoverDocuments(out var documents, out var sessionInfo))
         {
             showMissingFilesDialog = true;
-            return window;
+            return GetMainWindow(null);
         }
 
+        var window = GetMainWindow(sessionInfo?.AnalyticsSessionId);
+        var fileVM = window.services.GetRequiredService<FileViewModel>();
+
         var i = 0;
 
         foreach (var document in documents)
@@ -143,13 +143,13 @@ internal partial class MainWindow : Window
 
         return window;
 
-        MainWindow GetMainWindow()
+        MainWindow GetMainWindow(Guid? analyticsSession)
         {
             try
             {
                 var app = (App)Application.Current;
                 ClassicDesktopEntry entry = new(app.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime);
-                return new MainWindow(entry.InitApp());
+                return new MainWindow(entry.InitApp(), analyticsSession);
             }
             catch (Exception e)
             {

+ 14 - 6
src/PixiEditor/Views/Overlays/TransformOverlay/TransformUpdateHelper.cs

@@ -24,11 +24,15 @@ internal static class TransformUpdateHelper
             VecD oppositePos = TransformHelper.GetAnchorPosition(corners, opposite);
 
             // constrain desired pos to a "propotional" diagonal line if needed
-            if (freedom == TransformCornerFreedom.ScaleProportionally)
+            if (freedom == TransformCornerFreedom.ScaleProportionally && corners.IsRect)
             {
                 double correctAngle = targetCorner is Anchor.TopLeft or Anchor.BottomRight ? propAngle1 : propAngle2;
                 desiredPos = desiredPos.ProjectOntoLine(oppositePos, oppositePos + VecD.FromAngleAndLength(correctAngle, 1));
             }
+            else if (freedom == TransformCornerFreedom.ScaleProportionally)
+            {
+                desiredPos = desiredPos.ProjectOntoLine(oppositePos, targetPos);
+            }
 
             // find neighboring corners
             (Anchor leftNeighbor, Anchor rightNeighbor) = TransformHelper.GetNeighboringCorners(targetCorner);
@@ -55,11 +59,12 @@ internal static class TransformUpdateHelper
             }
             else
             {
-                // handle normal cases
-                VecD desiredTrans = (desiredPos - oppositePos).Rotate(-angle);
-                VecD scaling = desiredTrans.Divide(targetTrans);
-                leftNeighDelta = leftNeighTrans.Multiply(scaling) - leftNeighTrans;
-                rightNeighDelta = rightNeighTrans.Multiply(scaling) - rightNeighTrans;
+                VecD? newLeftPos = TransformHelper.TwoLineIntersection(VecD.Zero, leftNeighTrans, targetTrans + delta, leftNeighTrans + delta);
+                VecD? newRightPos = TransformHelper.TwoLineIntersection(VecD.Zero, rightNeighTrans, targetTrans + delta, rightNeighTrans + delta);
+                if (newLeftPos is null || newRightPos is null)
+                    return null;
+                leftNeighDelta = newLeftPos.Value - leftNeighTrans;
+                rightNeighDelta = newRightPos.Value - rightNeighTrans;
             }
 
             // handle cases where the transform overlay is squished into a line or a single point
@@ -87,6 +92,9 @@ internal static class TransformUpdateHelper
             corners = TransformHelper.UpdateCorner(corners, rightNeighbor,
                 (rightNeighTrans + rightNeighDelta).Rotate(angle) + oppositePos);
 
+            if (!corners.IsLegal)
+                return null;
+
             return corners;
         }