Browse Source

Merge pull request #699 from PixiEditor/vector-path-qol

Vector path qol
Krzysztof Krysiński 7 months ago
parent
commit
e4500a68e8

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 2ba04a8018897b11fa764943ce3c368b7eda4bc9
+Subproject commit 5e6f8e7762ea56e299bf6ab1a880996a7b856fef

+ 1 - 0
src/PixiEditor.Extensions/UI/Overlays/IOverlay.cs

@@ -4,6 +4,7 @@ using Drawie.Numerics;
 namespace PixiEditor.Extensions.UI.Overlays;
 
 public delegate void PointerEvent(OverlayPointerArgs args);
+public delegate void KeyEvent(Key key, KeyModifiers modifiers);
 public interface IOverlay
 {
     public void EnterPointer(OverlayPointerArgs args);

+ 4 - 2
src/PixiEditor/Data/Localization/Languages/en.json

@@ -768,7 +768,9 @@
   "PATH_TOOL": "Path",
   "PATH_TOOL_TOOLTIP": "Create vector paths and curves ({0}).",
   "PATH_TOOL_ACTION_DISPLAY": "Click to add a point.",
-  "PATH_TOOL_ACTION_DISPLAY_CTRL": "Click on existing point and drag to make it a curve.", 
+  "PATH_TOOL_ACTION_DISPLAY_CTRL": "Click on existing point and drag to make it a curve. Tap on a control point to select it.",
+  "PATH_TOOL_ACTION_DISPLAY_SHIFT": "Click on a path to insert a point.",
+  "PATH_TOOL_ACTION_DISPLAY_CTRL_SHIFT": "Tap on a control point to add it to the selection.",
   "PATH_TOOL_ACTION_DISPLAY_ALT": "Click on a control point and move to adjust only one side of the curve.",
   "DEFAULT_PATH_LAYER_NAME": "Path",
   "DELETE_NODES": "Delete nodes",
@@ -781,5 +783,5 @@
   "PREVIOUS_TOOL_SET": "Previous tool set",
   "FILL_MODE": "Fill mode",
   "USE_LINEAR_SRGB_PROCESSING": "Use linear sRGB for processing colors",
-    "USE_LINEAR_SRGB_PROCESSING_DESC": "Convert document using legacy blending mode to linear sRGB for processing colors. This will affect the colors of the document, but will make blending more accurate."
+  "USE_LINEAR_SRGB_PROCESSING_DESC": "Convert document using legacy blending mode to linear sRGB for processing colors. This will affect the colors of the document, but will make blending more accurate."
 }

+ 14 - 24
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs

@@ -1,4 +1,5 @@
-using Avalonia.Media;
+using Avalonia.Input;
+using Avalonia.Media;
 using ChunkyImageLib.DataHolders;
 using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
@@ -16,6 +17,7 @@ using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
 using PixiEditor.Models.Handlers.Toolbars;
 using PixiEditor.Models.Tools;
 using PixiEditor.ViewModels.Tools.Tools;
+using PixiEditor.Views.Overlays.PathOverlay;
 using Color = Drawie.Backend.Core.ColorsImpl.Color;
 using Colors = Drawie.Backend.Core.ColorsImpl.Colors;
 
@@ -97,9 +99,9 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
                 }
 
                 //below forces undo before starting new path
-                internals.ActionAccumulator.AddFinishedActions(new EndSetShapeGeometry_Action());
+                //internals.ActionAccumulator.AddFinishedActions(new EndSetShapeGeometry_Action());
 
-                internals.ActionAccumulator.AddActions(new SetShapeGeometry_Action(member.Id, ConstructShapeData()));
+                //internals.ActionAccumulator.AddActions(new SetShapeGeometry_Action(member.Id, ConstructShapeData()));
             }
         }
         else
@@ -135,7 +137,8 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
 
     public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
     {
-        if (!isValidPathLayer || startingPath.IsClosed) 
+        bool allClosed = WholePathClosed();
+        if (!isValidPathLayer || allClosed)
         {
             if (NeedsNewLayer(document.SelectedStructureMember, document.AnimationHandler.ActiveFrameTime))
             {
@@ -146,27 +149,14 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
 
                 document.Operations.SetSelectedMember(created.Value);
             }
-
-            return;
-        }
-
-        VecD mouseSnap =
-            document.SnappingHandler.SnappingController.GetSnapPoint(args.PositionOnCanvas, out _,
-                out _);
-
-        if (startingPath.Points.Count > 0 && startingPath.Points[0] == (VecF)mouseSnap)
-        {
-            startingPath.Close();
-        }
-        else
-        {
-            startingPath.LineTo((VecF)mouseSnap);
         }
-
-        PathVectorData vectorData = ConstructShapeData();
-
-        internals.ActionAccumulator.AddActions(new SetShapeGeometry_Action(member.Id, vectorData));
-        mouseDown = true;
+    }
+    
+    private bool WholePathClosed()
+    {
+        EditableVectorPath editablePath = new EditableVectorPath(startingPath);
+        
+        return editablePath.SubShapes.Count > 0 && editablePath.SubShapes.All(x => x.IsClosed);
     }
 
     public override void OnLeftMouseButtonUp(VecD pos)

+ 16 - 1
src/PixiEditor/ViewModels/Tools/Tools/VectorPathToolViewModel.cs

@@ -30,6 +30,8 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
     private LocalizedString actionDisplayDefault;
     private LocalizedString actionDisplayCtrl;
     private LocalizedString actionDisplayAlt;
+    private LocalizedString actionDisplayShift;
+    private LocalizedString actionDisplayCtrlShift;
 
     [Settings.Enum("FILL_MODE", PathFillType.Winding)]
     public PathFillType FillMode
@@ -49,6 +51,8 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
         actionDisplayDefault = new LocalizedString("PATH_TOOL_ACTION_DISPLAY");
         actionDisplayCtrl = new LocalizedString("PATH_TOOL_ACTION_DISPLAY_CTRL");
         actionDisplayAlt = new LocalizedString("PATH_TOOL_ACTION_DISPLAY_ALT");
+        actionDisplayShift = new LocalizedString("PATH_TOOL_ACTION_DISPLAY_SHIFT");
+        actionDisplayCtrlShift = new LocalizedString("PATH_TOOL_ACTION_DISPLAY_CTRL_SHIFT");
     }
 
     public override void UseTool(VecD pos)
@@ -69,12 +73,23 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
     {
         if (ctrlIsDown)
         {
-            ActionDisplay = actionDisplayCtrl;
+            if (shiftIsDown)
+            {
+                ActionDisplay = actionDisplayCtrlShift;
+            }
+            else
+            {
+                ActionDisplay = actionDisplayCtrl;
+            }
         }
         else if (altIsDown)
         {
             ActionDisplay = actionDisplayAlt;
         }
+        else if (shiftIsDown)
+        {
+            ActionDisplay = actionDisplayShift;
+        }
         else
         {
             ActionDisplay = actionDisplayDefault;

+ 1 - 1
src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml

@@ -169,7 +169,7 @@
                                SwitchToolSetCommand="{xaml:Command Name=PixiEditor.Tools.SwitchToolSet, UseProvided=True}" />
         </Grid>
         <rendering:Scene
-            Focusable="False" Name="scene"
+            Focusable="True" Name="scene"
             ZIndex="1"
             SceneRenderer="{Binding Source={viewModels:MainVM DocumentManagerSVM}, Path=ActiveDocument.SceneRenderer}"
             Document="{Binding Document, ElementName=vpUc, Mode=OneWay}"

+ 50 - 11
src/PixiEditor/Views/Overlays/Overlay.cs

@@ -50,7 +50,9 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     public event PointerEvent? PointerMovedOverlay;
     public event PointerEvent? PointerPressedOverlay;
     public event PointerEvent? PointerReleasedOverlay;
-    
+    public event KeyEvent? KeyPressedOverlay;
+    public event KeyEvent? KeyReleasedOverlay;
+
     public Handle? CapturedHandle { get; set; } = null!;
 
     private readonly Dictionary<AvaloniaProperty, OverlayTransition> activeTransitions = new();
@@ -87,42 +89,71 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     {
         CapturedHandle = handle;
     }
-    
+
     public void EnterPointer(OverlayPointerArgs args)
     {
         OnOverlayPointerEntered(args);
+        if (args.Handled) return;
         InvokeHandleEvent(HandleEventType.PointerEnteredOverlay, args);
+        if (args.Handled) return;
         PointerEnteredOverlay?.Invoke(args);
     }
 
     public void ExitPointer(OverlayPointerArgs args)
     {
         OnOverlayPointerExited(args);
+        if (args.Handled) return;
         InvokeHandleEvent(HandleEventType.PointerExitedOverlay, args);
+        if (args.Handled) return;
         PointerExitedOverlay?.Invoke(args);
     }
 
     public void MovePointer(OverlayPointerArgs args)
     {
-        OnOverlayPointerMoved(args);
         InvokeHandleEvent(HandleEventType.PointerMovedOverlay, args);
+        if (args.Handled) return;
+        OnOverlayPointerMoved(args);
+        if (args.Handled) return;
         PointerMovedOverlay?.Invoke(args);
     }
 
     public void PressPointer(OverlayPointerArgs args)
     {
-        OnOverlayPointerPressed(args);
         InvokeHandleEvent(HandleEventType.PointerPressedOverlay, args);
+        if (args.Handled) return;
+        OnOverlayPointerPressed(args);
+        if (args.Handled) return;
         PointerPressedOverlay?.Invoke(args);
     }
 
     public void ReleasePointer(OverlayPointerArgs args)
     {
-        OnOverlayPointerReleased(args);
         InvokeHandleEvent(HandleEventType.PointerReleasedOverlay, args);
+        if (args.Handled)
+        {
+            CaptureHandle(null);
+            return;
+        }
+        OnOverlayPointerReleased(args);
+        if (args.Handled)
+        {
+            CaptureHandle(null);
+            return;
+        }
+
         PointerReleasedOverlay?.Invoke(args);
-        
-        CaptureHandle(null);
+    }
+    
+    public void KeyPressed(KeyEventArgs args)
+    {
+        OnKeyPressed(args.Key, args.KeyModifiers);
+        KeyPressedOverlay?.Invoke(args.Key, args.KeyModifiers);
+    }
+
+    public void KeyReleased(KeyEventArgs keyEventArgs)
+    {
+        OnKeyReleased(keyEventArgs.Key, keyEventArgs.KeyModifiers);
+        KeyReleasedOverlay?.Invoke(keyEventArgs.Key, keyEventArgs.KeyModifiers);
     }
 
     public virtual bool TestHit(VecD point)
@@ -156,7 +187,7 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
             }
         }
     }
-    
+
     private void InvokeHandleEvent(HandleEventType eventName, OverlayPointerArgs args)
     {
         if (CapturedHandle != null)
@@ -172,11 +203,11 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
             }
         }
     }
-    
+
     private void InvokeHandleEvent(Handle handle, OverlayPointerArgs args, HandleEventType pointerEvent)
     {
-        if(pointerEvent == null) return;
-        
+        if (pointerEvent == null) return;
+
         if (pointerEvent == HandleEventType.PointerMovedOverlay)
         {
             handle.InvokeMove(args);
@@ -244,6 +275,14 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     protected virtual void OnOverlayPointerReleased(OverlayPointerArgs args)
     {
     }
+    
+    protected virtual void OnKeyPressed(Key key, KeyModifiers keyModifiers)
+    {
+    }
+    
+    protected virtual void OnKeyReleased(Key key, KeyModifiers keyModifiers)
+    {
+    }
 
     protected virtual void OnOverlayPointerPressed(OverlayPointerArgs args)
     {

+ 66 - 11
src/PixiEditor/Views/Overlays/PathOverlay/EditableVectorPath.cs

@@ -13,14 +13,14 @@ public class EditableVectorPath
         set
         {
             UpdatePathFrom(value);
-            path = value;            
+            path = value;
         }
     }
 
     private List<SubShape> subShapes = new List<SubShape>();
 
     public IReadOnlyList<SubShape> SubShapes => subShapes;
-    
+
     public int TotalPoints => subShapes.Sum(x => x.Points.Count);
 
     public int ControlPointsCount
@@ -65,12 +65,12 @@ public class EditableVectorPath
 
         return newPath;
     }
-    
+
     private static Verb CreateMoveToVerb(SubShape subShape)
     {
         VecF[] points = new VecF[4];
         points[0] = subShape.Points[0].Position;
-        
+
         return new Verb((PathVerb.Move, points, 0));
     }
 
@@ -89,7 +89,7 @@ public class EditableVectorPath
 
         List<ShapePoint> currentSubShapePoints = new List<ShapePoint>();
 
-        foreach(var data in from)
+        foreach (var data in from)
         {
             if (data.verb == PathVerb.Done)
             {
@@ -117,15 +117,23 @@ public class EditableVectorPath
                 subShapes.Add(new SubShape(currentSubShapePoints, isSubShapeClosed));
 
                 currentSubShapePoints.Clear();
-
-                currentSubShapeStartingIndex = globalVerbIndex;
             }
             else
             {
                 isSubShapeClosed = false;
                 if (data.verb == PathVerb.Move)
                 {
-                    currentSubShapePoints.Clear();
+                    if (currentSubShapePoints.Count > 0)
+                    {
+                        subShapes.Add(new SubShape(currentSubShapePoints, isSubShapeClosed));
+                        currentSubShapePoints.Clear();
+                        
+                        currentSubShapePoints.Add(new ShapePoint(data.points[0], 0, new Verb()));
+                    }
+                    else
+                    {
+                        currentSubShapePoints.Add(new ShapePoint(data.points[0], 0, new Verb()));
+                    }
                 }
                 else
                 {
@@ -136,7 +144,7 @@ public class EditableVectorPath
             globalVerbIndex++;
         }
     }
-    
+
     private void AddVerbToPath(Verb verb, VectorPath newPath)
     {
         if (verb.IsEmptyVerb())
@@ -203,13 +211,14 @@ public class EditableVectorPath
 
         return null;
     }
-    
+
     private int CountControlPoints(IReadOnlyList<ShapePoint> points)
     {
         int count = 0;
         foreach (var point in points)
         {
-            if(point.Verb.VerbType != PathVerb.Cubic) continue; // temporarily only cubic is supported for control points
+            if (point.Verb.VerbType != PathVerb.Cubic)
+                continue; // temporarily only cubic is supported for control points
             if (point.Verb.ControlPoint1 != null)
             {
                 count++;
@@ -255,4 +264,50 @@ public class EditableVectorPath
 
         return -1;
     }
+
+    public VecD? GetClosestPointOnPath(VecD point, float maxDistanceInPixels)
+    {
+        VecD? closest = null;
+
+        foreach (var subShape in subShapes)
+        {
+            VecD? closestInSubShape = subShape.GetClosestPointOnPath(point, maxDistanceInPixels);
+
+            if (closestInSubShape != null)
+            {
+                if (closest == null ||
+                    VecD.Distance(closestInSubShape.Value, point) < VecD.Distance(closest.Value, point))
+                {
+                    closest = closestInSubShape;
+                }
+            }
+        }
+
+        return closest;
+    }
+
+    public void AddPointAt(VecD point)
+    {
+        SubShape targetSubShape = null;
+        Verb verb = null;
+        foreach (var subShape in subShapes)
+        {
+            verb = subShape.FindVerbContainingPoint(point);
+            if (verb != null && !verb.IsEmptyVerb())
+            {
+                targetSubShape = subShape;
+                break;
+            }
+        }
+
+        targetSubShape?.InsertPointAt((VecF)point, verb);
+    }
+
+    public void RemoveSubShape(SubShape subShapeContainingIndex)
+    {
+        if (subShapes.Contains(subShapeContainingIndex))
+        {
+            subShapes.Remove(subShapeContainingIndex);
+        }
+    }
 }

+ 15 - 4
src/PixiEditor/Views/Overlays/PathOverlay/ShapePoint.cs

@@ -9,7 +9,7 @@ public class ShapePoint
 {
     public VecF Position { get; set; }
 
-    public int Index { get; }
+    public int Index { get; set; }
     public Verb Verb { get; set; }
 
     public ShapePoint(VecF position, int index, Verb verb)
@@ -38,11 +38,22 @@ public class ShapePoint
         {
             VecF mid1 = Verb.ControlPoint1 ?? Verb.From;
             
-            float factor = 2 * Verb.ConicWeight / (1 + Verb.ConicWeight);
+            float fixedConic = 1 - Verb.ConicWeight;
 
-            VecF control1 = mid1;// + new VecF((mid1.X - Verb.From.X) * factor, (mid1.Y - Verb.From.Y) * factor);
-            VecF control2 = Verb.To + new VecF((mid1.X - Verb.To.X) * factor, (mid1.Y - Verb.To.Y) * factor);
+            // TODO: Make sure it is adjusted/works for other cases
+            // 0.77 works for oval case, it probably will need to be adjusted for other cases
+            // we don't have a case for any other shape than oval so right now it's hardcoded like so.
+            float factor = 2 * fixedConic / (0.77f + fixedConic);
+
+            VecF from1 = (mid1 - Verb.From);
+            from1 = new VecF(from1.X * factor, from1.Y * factor);
             
+            VecF from2 = (mid1 - Verb.To);
+            from2 = new VecF(from2.X * factor, from2.Y * factor);
+
+            VecF control1 = Verb.From + from1;
+            VecF control2 = Verb.To + from2;
+
             return [Verb.From, control1, control2, Verb.To];
         }
         

+ 169 - 3
src/PixiEditor/Views/Overlays/PathOverlay/SubShape.cs

@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
 
 namespace PixiEditor.Views.Overlays.PathOverlay;
@@ -9,7 +10,7 @@ public class SubShape
     private List<ShapePoint> points;
 
     public IReadOnlyList<ShapePoint> Points => points;
-    public bool IsClosed { get; }
+    public bool IsClosed { get; private set; }
 
     public ShapePoint? GetNextPoint(int nextToIndex)
     {
@@ -37,6 +38,45 @@ public class SubShape
         IsClosed = isClosed;
     }
 
+    public void RemovePoint(int i)
+    {
+        bool isFirst = i == 0;
+        bool isLast = i == points.Count - 1;
+
+        if (!isFirst)
+        {
+            var previousPoint = GetPreviousPoint(i);
+            var nextPoint = GetNextPoint(i);
+
+            if (previousPoint?.Verb != null && nextPoint?.Verb != null)
+            {
+                previousPoint.Verb.To = nextPoint.Position;
+            }
+            
+            for (int j = i + 1; j < points.Count; j++)
+            {
+                points[j].Index--;
+            }
+        }
+
+        if (isLast)
+        {
+            points[^2].Verb = new Verb();
+        }
+
+        if (isFirst && points.Count > 2)
+        {
+            points[^1].Verb.To = points[1].Position;
+        }
+
+        points.RemoveAt(i);
+        
+        if(points.Count < 3)
+        {
+            IsClosed = false;
+        }
+    }
+
     public void SetPointPosition(int i, VecF newPos, bool updateControlPoints)
     {
         var shapePoint = points[i];
@@ -51,7 +91,6 @@ public class SubShape
             {
                 shapePoint.Verb.ControlPoint1 = shapePoint.Verb.ControlPoint1.Value + delta;
             }
-            
         }
 
         var previousPoint = GetPreviousPoint(i);
@@ -59,7 +98,7 @@ public class SubShape
         if (previousPoint?.Verb != null && previousPoint.Verb.To == oldPos)
         {
             previousPoint.Verb.To = newPos;
-            
+
             if (updateControlPoints)
             {
                 if (previousPoint.Verb.ControlPoint2 != null)
@@ -69,4 +108,131 @@ public class SubShape
             }
         }
     }
+
+    public void AppendPoint(VecF point)
+    {
+        if (points.Count == 0)
+        {
+            VecF[] data = new VecF[4];
+            data[0] = VecF.Zero;
+            data[1] = point;
+            points.Add(new ShapePoint(point, 0, new Verb((PathVerb.Move, data, 0))));
+        }
+        else
+        {
+            var lastPoint = points[^1];
+            VecF[] data = new VecF[4];
+            data[0] = lastPoint.Position;
+            data[1] = point;
+            points.Add(new ShapePoint(point, lastPoint.Index + 1, new Verb((PathVerb.Line, data, 0))));
+        }
+    }
+
+    public void InsertPointAt(VecF point, Verb pointVerb)
+    {
+        int indexOfVerb = this.points.FirstOrDefault(x => x.Verb == pointVerb)?.Index ?? -1;
+        if (indexOfVerb == -1)
+        {
+            throw new ArgumentException("Verb not found in points list");
+        }
+
+        Verb onVerb = pointVerb;
+
+        if (onVerb.VerbType is PathVerb.Quad or PathVerb.Conic)
+        {
+            this.points[indexOfVerb].ConvertVerbToCubic();
+            onVerb = this.points[indexOfVerb].Verb;
+        }
+
+        var oldTo = onVerb.To;
+        VecF[] data = new VecF[4];
+        VecF insertPoint = point;
+
+        if (onVerb.VerbType == PathVerb.Line)
+        {
+            onVerb.To = point;
+            data = [onVerb.To, oldTo, VecF.Zero, VecF.Zero];
+        }
+        else
+        {
+            float t = VectorMath.GetNormalizedSegmentPosition(onVerb, point);
+            VecD oldControlPoint1 = (VecD)onVerb.ControlPoint1.Value;
+            VecD oldControlPoint2 = (VecD)onVerb.ControlPoint2.Value;
+
+            // de Casteljau's algorithm
+
+            var q0 = ((VecD)onVerb.From).Lerp(oldControlPoint1, t);
+            var q1 = oldControlPoint1.Lerp(oldControlPoint2, t);
+            var q2 = oldControlPoint2.Lerp((VecD)oldTo, t);
+
+            var r0 = q0.Lerp(q1, t);
+            var r1 = q1.Lerp(q2, t);
+
+            var s0 = r0.Lerp(r1, t);
+
+            onVerb.ControlPoint1 = (VecF)q0;
+            onVerb.ControlPoint2 = (VecF)r0;
+
+            onVerb.To = (VecF)s0;
+
+            data = [(VecF)s0, (VecF)r1, (VecF)q2, oldTo];
+
+            insertPoint = (VecF)s0;
+        }
+
+        this.points.Insert(indexOfVerb + 1,
+            new ShapePoint(insertPoint, indexOfVerb + 1, new Verb((onVerb.VerbType.Value, data, 0))));
+
+        for (int i = indexOfVerb + 2; i < this.points.Count; i++)
+        {
+            this.points[i].Index++;
+        }
+    }
+
+    public VecD? GetClosestPointOnPath(VecD point, float maxDistanceInPixels)
+    {
+        for (int i = 0; i < points.Count; i++)
+        {
+            var currentPoint = points[i];
+
+            VecD? closest = VectorMath.GetClosestPointOnSegment(point, currentPoint.Verb);
+            if (closest != null && VecD.Distance(closest.Value, point) < maxDistanceInPixels)
+            {
+                return closest;
+            }
+        }
+
+        return null;
+    }
+
+    public Verb? FindVerbContainingPoint(VecD point)
+    {
+        foreach (var shapePoint in points)
+        {
+            if (VectorMath.IsPointOnSegment(point, shapePoint.Verb))
+            {
+                return shapePoint.Verb;
+            }
+        }
+
+        return null;
+    }
+
+    public void Close()
+    {
+        if (IsClosed)
+        {
+            return;
+        }
+
+        IsClosed = true;
+
+        if (points.Count > 1)
+        {
+            VecF[] data = new VecF[4];
+            data[0] = points[^1].Position;
+            data[1] = points[0].Position;
+            points.Add(new ShapePoint(points[0].Position, points[^1].Index + 1, new Verb((PathVerb.Line, data, 0))));
+        }
+    }
 }

+ 227 - 0
src/PixiEditor/Views/Overlays/PathOverlay/VectorMath.cs

@@ -0,0 +1,227 @@
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+
+namespace PixiEditor.Views.Overlays.PathOverlay;
+
+internal static class VectorMath
+{
+    public static VecD? GetClosestPointOnSegment(VecD point, Verb verb)
+    {
+        if (verb == null || verb.IsEmptyVerb()) return null;
+
+        switch (verb.VerbType)
+        {
+            case PathVerb.Move:
+                return (VecD)verb.From;
+            case PathVerb.Line:
+                return ClosestPointOnLine((VecD)verb.From, (VecD)verb.To, point);
+            case PathVerb.Quad:
+                return GetClosestPointOnQuad(point, (VecD)verb.From, (VecD)(verb.ControlPoint1 ?? verb.From),
+                    (VecD)verb.To);
+            case PathVerb.Conic:
+                return GetClosestPointOnConic(point, (VecD)verb.From, (VecD)(verb.ControlPoint1 ?? verb.From),
+                    (VecD)verb.To,
+                    verb.ConicWeight);
+            case PathVerb.Cubic:
+                return GetClosestPointOnCubic(point, (VecD)verb.From, (VecD)(verb.ControlPoint1 ?? verb.From),
+                    (VecD)(verb.ControlPoint2 ?? verb.To), (VecD)verb.To);
+            case PathVerb.Close:
+                return (VecD)verb.From;
+            case PathVerb.Done:
+                break;
+            case null:
+                break;
+            default:
+                throw new ArgumentOutOfRangeException();
+        }
+
+        return null;
+    }
+
+    public static bool IsPointOnSegment(VecD point, Verb shapePointVerb)
+    {
+        if (shapePointVerb.IsEmptyVerb()) return false;
+
+        switch (shapePointVerb.VerbType)
+        {
+            case PathVerb.Move:
+                return Math.Abs(point.X - shapePointVerb.From.X) < 0.0001 &&
+                       Math.Abs(point.Y - shapePointVerb.From.Y) < 0.0001;
+            case PathVerb.Line:
+                return IsPointOnLine(point, (VecD)shapePointVerb.From, (VecD)shapePointVerb.To);
+            case PathVerb.Quad:
+                return IsPointOnQuad(point, (VecD)shapePointVerb.From,
+                    (VecD)(shapePointVerb.ControlPoint1 ?? shapePointVerb.From),
+                    (VecD)shapePointVerb.To);
+            case PathVerb.Conic:
+                return IsPointOnConic(point, (VecD)shapePointVerb.From,
+                    (VecD)(shapePointVerb.ControlPoint1 ?? shapePointVerb.From),
+                    (VecD)shapePointVerb.To, shapePointVerb.ConicWeight);
+            case PathVerb.Cubic:
+                return IsPointOnCubic(point, (VecD)shapePointVerb.From,
+                    (VecD)(shapePointVerb.ControlPoint1 ?? shapePointVerb.From),
+                    (VecD)(shapePointVerb.ControlPoint2 ?? shapePointVerb.To), (VecD)shapePointVerb.To);
+            case PathVerb.Close:
+                break;
+            case PathVerb.Done:
+                break;
+            case null:
+                break;
+            default:
+                throw new ArgumentOutOfRangeException();
+        }
+
+        return false;
+    }
+
+    public static VecD ClosestPointOnLine(VecD start, VecD end, VecD point)
+    {
+        VecD startToPoint = point - start;
+        VecD startToEnd = end - start;
+
+        double sqrtMagnitudeToEnd = Math.Pow(startToEnd.X, 2) + Math.Pow(startToEnd.Y, 2);
+
+        double dot = startToPoint.X * startToEnd.X + startToPoint.Y * startToEnd.Y;
+        var t = dot / sqrtMagnitudeToEnd;
+
+        if (t < 0) return start;
+        if (t > 1) return end;
+
+        return start + startToEnd * t;
+    }
+
+    public static bool IsPointOnLine(VecD point, VecD start, VecD end)
+    {
+        return Math.Abs(VecD.Distance(start, point) + VecD.Distance(end, point) - VecD.Distance(start, end)) < 0.001f;
+    }
+
+    public static VecD GetClosestPointOnQuad(VecD point, VecD start, VecD controlPoint, VecD end)
+    {
+        return FindClosestPointBruteForce(point, (t) => QuadraticBezier(start, controlPoint, end, t));
+    }
+
+    public static VecD GetClosestPointOnCubic(VecD point, VecD start, VecD controlPoint1, VecD controlPoint2, VecD end)
+    {
+        return FindClosestPointBruteForce(point, (t) => CubicBezier(start, controlPoint1, controlPoint2, end, t));
+    }
+
+    public static VecD GetClosestPointOnConic(VecD point, VecD start, VecD controlPoint, VecD end, float weight)
+    {
+        return FindClosestPointBruteForce(point, (t) => ConicBezier(start, controlPoint, end, weight, t));
+    }
+
+    public static bool IsPointOnQuad(VecD point, VecD start, VecD controlPoint, VecD end)
+    {
+        return IsPointOnPath(point, (t) => QuadraticBezier(start, controlPoint, end, t));
+    }
+
+    public static bool IsPointOnCubic(VecD point, VecD start, VecD controlPoint1, VecD controlPoint2, VecD end)
+    {
+        return IsPointOnPath(point, (t) => CubicBezier(start, controlPoint1, controlPoint2, end, t));
+    }
+
+    public static bool IsPointOnConic(VecD point, VecD start, VecD controlPoint, VecD end, float weight)
+    {
+        return IsPointOnPath(point, (t) => ConicBezier(start, controlPoint, end, weight, t));
+    }
+
+    /// <summary>
+    ///     Finds value from 0 to 1 that represents the position of point on the segment.
+    /// </summary>
+    /// <param name="onVerb">Verb that represents the segment.</param>
+    /// <param name="point">Point that is on the segment.</param>
+    /// <returns>Value from 0 to 1 that represents the position of point on the segment.</returns>
+    public static float GetNormalizedSegmentPosition(Verb onVerb, VecF point)
+    {
+        if (onVerb.IsEmptyVerb()) return 0;
+
+        if (onVerb.VerbType == PathVerb.Cubic)
+        {
+            return (float)FindNormalizedSegmentPositionBruteForce(point, (t) =>
+                CubicBezier((VecD)onVerb.From, (VecD)(onVerb.ControlPoint1 ?? onVerb.From),
+                    (VecD)(onVerb.ControlPoint2 ?? onVerb.To), (VecD)onVerb.To, t));
+        }
+        
+        throw new NotImplementedException();
+    }
+
+    private static VecD FindClosestPointBruteForce(VecD point, Func<double, VecD> func, double step = 0.001)
+    {
+        double minDistance = double.MaxValue;
+        VecD closestPoint = new VecD();
+        for (double t = 0; t <= 1; t += step)
+        {
+            VecD currentPoint = func(t);
+            double distance = VecD.Distance(point, currentPoint);
+            if (distance < minDistance)
+            {
+                minDistance = distance;
+                closestPoint = currentPoint;
+            }
+        }
+
+        return closestPoint;
+    }
+
+    private static double FindNormalizedSegmentPositionBruteForce(VecF point, Func<double, VecD> func,
+        double step = 0.001)
+    {
+        double minDistance = float.MaxValue;
+        double closestT = 0;
+        for (double t = 0; t <= 1; t += step)
+        {
+            VecD currentPoint = func(t);
+            float distance = (point - currentPoint).Length;
+            if (distance < minDistance)
+            {
+                minDistance = distance;
+                closestT = t;
+            }
+        }
+
+        return closestT;
+    }
+
+    private static bool IsPointOnPath(VecD point, Func<double, VecD> func, double step = 0.001)
+    {
+        for (double t = 0; t <= 1; t += step)
+        {
+            VecD currentPoint = func(t);
+            if (VecD.Distance(point, currentPoint) < 0.1)
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static VecD QuadraticBezier(VecD start, VecD control, VecD end, double t)
+    {
+        double x = Math.Pow(1 - t, 2) * start.X + 2 * (1 - t) * t * control.X + Math.Pow(t, 2) * end.X;
+        double y = Math.Pow(1 - t, 2) * start.Y + 2 * (1 - t) * t * control.Y + Math.Pow(t, 2) * end.Y;
+        return new VecD(x, y);
+    }
+
+    private static VecD CubicBezier(VecD start, VecD control1, VecD control2, VecD end, double t)
+    {
+        double x = Math.Pow(1 - t, 3) * start.X + 3 * Math.Pow(1 - t, 2) * t * control1.X +
+                   3 * (1 - t) * Math.Pow(t, 2) * control2.X + Math.Pow(t, 3) * end.X;
+        double y = Math.Pow(1 - t, 3) * start.Y + 3 * Math.Pow(1 - t, 2) * t * control1.Y +
+                   3 * (1 - t) * Math.Pow(t, 2) * control2.Y + Math.Pow(t, 3) * end.Y;
+        return new VecD(x, y);
+    }
+
+    private static VecD ConicBezier(VecD start, VecD control, VecD end, float weight, double t)
+    {
+        double b0 = (1 - t) * (1 - t);
+        double b1 = 2 * t * (1 - t);
+        double b2 = t * t;
+
+        VecD numerator = (start * b0) + (control * b1 * weight) + (end * b2);
+
+        double denominator = b0 + (b1 * weight) + b2;
+
+        return numerator / denominator;
+    }
+}

+ 268 - 38
src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs

@@ -50,6 +50,7 @@ public class VectorPathOverlay : Overlay
 
     private DashedStroke dashedStroke = new DashedStroke();
     private TransformHandle transformHandle;
+    private AnchorHandle insertPreviewHandle;
 
     private List<AnchorHandle> anchorHandles = new();
     private List<ControlPointHandle> controlPointHandles = new();
@@ -58,6 +59,7 @@ public class VectorPathOverlay : Overlay
     private VectorPath pathOnStartDrag;
 
     private EditableVectorPath editableVectorPath;
+    private bool canInsert = false;
 
     static VectorPathOverlay()
     {
@@ -72,6 +74,11 @@ public class VectorPathOverlay : Overlay
         transformHandle.OnDrag += MoveHandleDrag;
 
         AddHandle(transformHandle);
+
+        insertPreviewHandle = new AnchorHandle(this);
+        insertPreviewHandle.HitTestVisible = false;
+
+        AddHandle(insertPreviewHandle);
     }
 
     protected override void ZoomChanged(double newZoom)
@@ -91,7 +98,12 @@ public class VectorPathOverlay : Overlay
 
         RenderHandles(context);
 
-        if (IsOverAnyHandle())
+        if (canInsert)
+        {
+            insertPreviewHandle.Draw(context);
+        }
+
+        if (IsOverAnyHandle() || canInsert)
         {
             TryHighlightSnap(null, null);
         }
@@ -99,7 +111,7 @@ public class VectorPathOverlay : Overlay
 
     public override bool CanRender()
     {
-        return Path != null;
+        return Path is { IsEmpty: false };
     }
 
     private void RenderHandles(Canvas context)
@@ -108,6 +120,8 @@ public class VectorPathOverlay : Overlay
 
         EditableVectorPath editablePath = new EditableVectorPath(Path);
 
+        UpdatePointsPositions();
+
         int anchorIndex = 0;
         int controlPointIndex = 0;
         for (int i = 0; i < editablePath.SubShapes.Count; i++)
@@ -121,8 +135,12 @@ public class VectorPathOverlay : Overlay
 
             foreach (var point in subPath.Points)
             {
+                if (anchorIndex >= anchorHandles.Count)
+                {
+                    break;
+                }
+
                 var handle = anchorHandles[anchorIndex];
-                handle.Position = (VecD)point.Position;
 
                 if (point.Verb.ControlPoint1 != null || point.Verb.ControlPoint2 != null)
                 {
@@ -146,7 +164,8 @@ public class VectorPathOverlay : Overlay
         if (point.Verb.ControlPoint1 != null)
         {
             var controlPoint1 = controlPointHandles[controlPointIndex];
-            controlPoint1.HitTestVisible = controlPoint1.Position != controlPoint1.ConnectedTo.Position;
+            controlPoint1.HitTestVisible = CapturedHandle == controlPoint1 ||
+                                           controlPoint1.Position != controlPoint1.ConnectedTo.Position;
             controlPoint1.Position = (VecD)point.Verb.ControlPoint1;
             if (controlPoint1.HitTestVisible)
             {
@@ -160,7 +179,8 @@ public class VectorPathOverlay : Overlay
         {
             var controlPoint2 = controlPointHandles[controlPointIndex];
             controlPoint2.Position = (VecD)point.Verb.ControlPoint2;
-            controlPoint2.HitTestVisible = controlPoint2.Position != controlPoint2.ConnectedTo.Position;
+            controlPoint2.HitTestVisible = CapturedHandle == controlPoint2 ||
+                                           controlPoint2.Position != controlPoint2.ConnectedTo.Position;
 
             if (controlPoint2.HitTestVisible)
             {
@@ -171,6 +191,11 @@ public class VectorPathOverlay : Overlay
         }
     }
 
+    public override bool TestHit(VecD point)
+    {
+        return Path != null;
+    }
+
     private void AdjustHandles(EditableVectorPath path)
     {
         int pointsCount = path.TotalPoints + path.ControlPointsCount;
@@ -195,15 +220,43 @@ public class VectorPathOverlay : Overlay
                 CreateHandle(controlPointHandles.Count, true);
             }
 
-
             SelectAnchor(GetHandleAt(pointsCount - 1));
 
             ConnectControlPointsToAnchors();
         }
 
+        UpdatePointsPositions();
         Refresh();
     }
 
+    private void UpdatePointsPositions()
+    {
+        int controlPointIndex = 0;
+        int anchorIndex = 0;
+        foreach (var subShape in editableVectorPath.SubShapes)
+        {
+            foreach (var point in subShape.Points)
+            {
+                if (point.Verb.VerbType == PathVerb.Cubic)
+                {
+                    var controlPoint1 = controlPointHandles[controlPointIndex];
+                    var controlPoint2 = controlPointHandles[controlPointIndex + 1];
+
+                    controlPoint1.Position = (VecD)point.Verb.ControlPoint1;
+                    controlPoint2.Position = (VecD)point.Verb.ControlPoint2;
+
+                    controlPointIndex += 2;
+                }
+
+                if (anchorIndex >= anchorHandles.Count) continue;
+
+                var anchor = anchorHandles[anchorIndex];
+                anchor.Position = (VecD)point.Position;
+                anchorIndex++;
+            }
+        }
+    }
+
     private void ConnectControlPointsToAnchors()
     {
         if (controlPointHandles.Count == 0)
@@ -261,6 +314,7 @@ public class VectorPathOverlay : Overlay
         for (int i = controlPointHandles.Count - 1; i >= 0; i--)
         {
             var handle = controlPointHandles[i];
+            handle.OnPress -= OnControlPointPress;
             handle.OnDrag -= OnControlPointDrag;
             handle.OnRelease -= OnHandleRelease;
 
@@ -303,6 +357,46 @@ public class VectorPathOverlay : Overlay
         args.Handled = true;
     }
 
+    protected override void OnKeyPressed(Key key, KeyModifiers keyModifiers)
+    {
+        if (key == Key.Delete)
+        {
+            DeleteSelectedPoints();
+        }
+    }
+
+    private void DeleteSelectedPoints()
+    {
+        var selectedHandles = anchorHandles.Where(h => h.IsSelected).ToList();
+        if (selectedHandles == null || selectedHandles.Count == 0)
+        {
+            return;
+        }
+        
+        int handleAdjustment = 0;
+
+        foreach (var handle in selectedHandles)
+        {
+            int index = anchorHandles.IndexOf(handle) - handleAdjustment;
+            SubShape subShapeContainingIndex = editableVectorPath.GetSubShapeContainingIndex(index);
+            int localIndex = editableVectorPath.GetSubShapePointIndex(index, subShapeContainingIndex);
+
+            if (subShapeContainingIndex.Points.Count == 1)
+            {
+                editableVectorPath.RemoveSubShape(subShapeContainingIndex);
+            }
+            else
+            {
+                subShapeContainingIndex.RemovePoint(localIndex);
+            }
+            
+            handleAdjustment++;
+        }
+        
+        Path = editableVectorPath.ToVectorPath();
+        AddToUndoCommand.Execute(Path);
+    }
+
     private void CreateHandle(int atIndex, bool isControlPoint = false)
     {
         if (!isControlPoint)
@@ -315,11 +409,17 @@ public class VectorPathOverlay : Overlay
             anchor.OnRelease += OnHandleRelease;
             anchor.OnTap += OnHandleTap;
             AddHandle(anchor);
-            SnappingController.AddXYAxis($"editingPath[{atIndex}]", () => anchor.Position);
+            SnappingController.AddXYAxis($"editingPath[{atIndex}]", () =>
+            {
+                var subs = editableVectorPath.GetSubShapeContainingIndex(atIndex);
+                int localIndex = editableVectorPath.GetSubShapePointIndex(atIndex, subs);
+                return (VecD)subs.Points[localIndex].Position;
+            });
         }
         else
         {
             var controlPoint = new ControlPointHandle(this);
+            controlPoint.OnPress += OnControlPointPress;
             controlPoint.OnDrag += OnControlPointDrag;
             controlPoint.OnRelease += OnHandleRelease;
             controlPointHandles.Add(controlPoint);
@@ -334,58 +434,143 @@ public class VectorPathOverlay : Overlay
             return;
         }
 
-        if (Path.IsClosed)
+        if (args.Modifiers.HasFlag(KeyModifiers.Control))
         {
+            bool append = args.Modifiers.HasFlag(KeyModifiers.Shift);
+            SelectAnchor(anchorHandle, append);
             return;
         }
 
-        VectorPath newPath = new VectorPath(Path);
-        if (args.Modifiers.HasFlag(KeyModifiers.Control))
+        var selectedHandle = anchorHandles.FirstOrDefault(h => h.IsSelected);
+        if (selectedHandle == null)
+        {
+            return;
+        }
+
+        SubShape ssOfSelected = editableVectorPath.GetSubShapeContainingIndex(anchorHandles.IndexOf(selectedHandle));
+        SubShape ssOfTapped = editableVectorPath.GetSubShapeContainingIndex(anchorHandles.IndexOf(anchorHandle));
+
+        if (ssOfTapped == null || ssOfSelected == null)
         {
-            SelectAnchor(anchorHandle);
             return;
         }
 
-        if (anchorHandles.IndexOf(anchorHandle) == 0)
+        int globalIndexOfTapped = anchorHandles.IndexOf(anchorHandle);
+        int localIndexOfTapped = editableVectorPath.GetSubShapePointIndex(globalIndexOfTapped, ssOfTapped);
+
+        if (ssOfSelected == ssOfTapped && ssOfTapped.IsClosed)
         {
-            newPath.LineTo((VecF)anchorHandle.Position);
-            newPath.Close();
+            return;
+        }
+
+        if (ssOfSelected == ssOfTapped && !ssOfTapped.IsClosed &&
+            (localIndexOfTapped == 0 || localIndexOfTapped == ssOfTapped.Points.Count - 1))
+        {
+            ssOfTapped.Close();
         }
         else
         {
-            VecD pos = anchorHandle.Position;
-            newPath.LineTo(new VecF((float)pos.X, (float)pos.Y));
+            ssOfTapped.AppendPoint((VecF)anchorHandle.Position);
         }
 
-        Path = newPath;
+        SelectAnchor(anchorHandle);
+        Path = editableVectorPath.ToVectorPath();
     }
 
-    private void SelectAnchor(AnchorHandle handle)
+    private void SelectAnchor(AnchorHandle handle, bool append = false)
     {
+        if (append)
+        {
+            handle.IsSelected = !handle.IsSelected;
+            return;
+        }
+
         foreach (var anchorHandle in anchorHandles)
         {
             anchorHandle.IsSelected = anchorHandle == handle;
         }
     }
 
-    private void OnHandlePress(Handle source, OverlayPointerArgs args)
+    protected override void OnOverlayPointerPressed(OverlayPointerArgs args)
     {
-        if (source is AnchorHandle anchorHandle)
+        if (args.PointerButton != MouseButton.Left)
         {
-            SnappingController.RemoveAll($"editingPath[{anchorHandles.IndexOf(anchorHandle)}]");
-            CaptureHandle(source);
+            return;
+        }
 
-            if (!args.Modifiers.HasFlag(KeyModifiers.Control)) return;
+        if (args.Modifiers == KeyModifiers.Shift && IsOverPath(args.Point, out VecD closestPoint))
+        {
+            AddPointAt(closestPoint);
+            AddToUndoCommand.Execute(Path);
+            args.Handled = true;
+        }
+        else if (args.Modifiers == KeyModifiers.None)
+        {
+            args.Handled = AddNewPointFromClick(args.Point);
+            AddToUndoCommand.Execute(Path);
+        }
+    }
 
-            var newPath = ConvertTouchingVerbsToCubic(anchorHandle);
+    protected override void OnOverlayPointerMoved(OverlayPointerArgs args)
+    {
+        if (args.Modifiers == KeyModifiers.Shift && IsOverPath(args.Point, out VecD closestPoint))
+        {
+            insertPreviewHandle.Position = closestPoint;
+            canInsert = true;
+        }
+        else
+        {
+            canInsert = false;
+        }
+    }
 
-            int index = anchorHandles.IndexOf(anchorHandle);
-            SubShape subShapeContainingIndex = editableVectorPath.GetSubShapeContainingIndex(index);
-            int localIndex = editableVectorPath.GetSubShapePointIndex(index, subShapeContainingIndex);
+    private bool AddNewPointFromClick(VecD point)
+    {
+        var selectedHandle = anchorHandles.FirstOrDefault(h => h.IsSelected);
+        SubShape subShape = editableVectorPath.GetSubShapeContainingIndex(anchorHandles.IndexOf(selectedHandle));
 
-            HandleContinousCubicDrag(anchorHandle.Position, anchorHandle, subShapeContainingIndex, localIndex);
+        if (subShape.IsClosed)
+        {
+            return false;
+        }
 
-            Path = newPath.ToVectorPath();
+        if (Path.IsEmpty)
+        {
+            Path = new VectorPath();
+            Path.MoveTo((VecF)point);
+            SelectAnchor(anchorHandles[0]);
+        }
+        else
+        {
+            subShape.AppendPoint((VecF)point);
+            Path = editableVectorPath.ToVectorPath();
+            SelectAnchor(anchorHandles.Last());
+        }
+        
+        return true;
+    }
+
+    private void AddPointAt(VecD point)
+    {
+        editableVectorPath.AddPointAt(point);
+        Path = editableVectorPath.ToVectorPath();
+    }
+
+    private bool IsOverPath(VecD point, out VecD closestPoint)
+    {
+        VecD? closest = editableVectorPath.GetClosestPointOnPath(point, 20 / (float)ZoomScale);
+        closestPoint = closest ?? point;
+
+        return closest != null;
+    }
+
+    private void OnHandlePress(Handle source, OverlayPointerArgs args)
+    {
+        if (source is AnchorHandle anchorHandle)
+        {
+            SnappingController.RemoveAll($"editingPath[{anchorHandles.IndexOf(anchorHandle)}]");
+            CaptureHandle(source);
+            args.Handled = true;
         }
     }
 
@@ -394,16 +579,21 @@ public class VectorPathOverlay : Overlay
     {
         int index = anchorHandles.IndexOf(anchorHandle);
 
+        return ConvertTouchingVerbsToCubic(index);
+    }
+
+    private EditableVectorPath ConvertTouchingVerbsToCubic(int index)
+    {
         SubShape subShapeContainingIndex = editableVectorPath.GetSubShapeContainingIndex(index);
 
         int localIndex = editableVectorPath.GetSubShapePointIndex(index, subShapeContainingIndex);
 
         var previousPoint = subShapeContainingIndex.GetPreviousPoint(localIndex);
-        var nextPoint = subShapeContainingIndex.Points[localIndex];
+        var point = subShapeContainingIndex.Points[localIndex];
 
         previousPoint?.ConvertVerbToCubic();
 
-        nextPoint.ConvertVerbToCubic();
+        point.ConvertVerbToCubic();
 
         return editableVectorPath;
     }
@@ -427,7 +617,12 @@ public class VectorPathOverlay : Overlay
 
         if (isDraggingControlPoints)
         {
-            HandleContinousCubicDrag(targetPos, handle, subShapeContainingIndex, localIndex);
+            var newPath = ConvertTouchingVerbsToCubic(handle);
+
+            subShapeContainingIndex = newPath.GetSubShapeContainingIndex(index);
+            localIndex = newPath.GetSubShapePointIndex(index, subShapeContainingIndex);
+
+            HandleContinousCubicDrag(targetPos, handle, subShapeContainingIndex, localIndex, true);
         }
         else
         {
@@ -438,9 +633,39 @@ public class VectorPathOverlay : Overlay
     }
 
     private void HandleContinousCubicDrag(VecD targetPos, AnchorHandle handle, SubShape subShapeContainingIndex,
-        int localIndex, bool swapOrder = false)
+        int localIndex, bool constrainRatio, bool swapOrder = false)
     {
-        var symmetricPos = GetMirroredControlPoint((VecF)targetPos, (VecF)handle.Position);
+        var previousPoint = subShapeContainingIndex.GetPreviousPoint(localIndex);
+
+        VecD symmetricPos = targetPos;
+        bool canMirror = true;
+
+        var thisPoint = subShapeContainingIndex.Points[localIndex];
+
+        if (constrainRatio)
+        {
+            symmetricPos = GetMirroredControlPoint((VecF)targetPos, thisPoint.Position);
+        }
+        else
+        {
+            VecD direction = targetPos - (VecD)thisPoint.Position;
+            direction = direction.Normalize();
+            var controlPos = ((VecD?)previousPoint?.Verb.ControlPoint2 ?? targetPos);
+            if (swapOrder)
+            {
+                controlPos = ((VecD?)subShapeContainingIndex.Points[localIndex]?.Verb.ControlPoint1 ?? targetPos);
+            }
+
+            double length = VecD.Distance((VecD)thisPoint.Position, controlPos);
+            if (!direction.IsNaNOrInfinity())
+            {
+                symmetricPos = (VecD)thisPoint.Position - direction * length;
+            }
+            else
+            {
+                canMirror = false;
+            }
+        }
 
         if (swapOrder)
         {
@@ -449,9 +674,7 @@ public class VectorPathOverlay : Overlay
 
         subShapeContainingIndex.Points[localIndex].Verb.ControlPoint1 = (VecF)targetPos;
 
-        var previousPoint = subShapeContainingIndex.GetPreviousPoint(localIndex);
-
-        if (previousPoint != null)
+        if (previousPoint != null && canMirror)
         {
             previousPoint.Verb.ControlPoint2 = (VecF)symmetricPos;
         }
@@ -477,7 +700,9 @@ public class VectorPathOverlay : Overlay
         if (!dragOnlyOne)
         {
             bool isDraggingFirst = controlPointHandles.IndexOf(controlPointHandle) % 2 == 0;
-            HandleContinousCubicDrag(targetPos, to, subShapeContainingIndex, localIndex, !isDraggingFirst);
+            bool constrainRatio = args.Modifiers.HasFlag(KeyModifiers.Control);
+            HandleContinousCubicDrag(targetPos, to, subShapeContainingIndex, localIndex, constrainRatio,
+                !isDraggingFirst);
         }
         else
         {
@@ -512,6 +737,11 @@ public class VectorPathOverlay : Overlay
         return snapped;
     }
 
+    private void OnControlPointPress(Handle source, OverlayPointerArgs args)
+    {
+        CaptureHandle(source);
+    }
+
     private void OnHandleRelease(Handle source, OverlayPointerArgs args)
     {
         AddToUndoCommand.Execute(Path);

+ 26 - 0
src/PixiEditor/Views/Rendering/Scene.cs

@@ -445,6 +445,32 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         }
     }
 
+    protected override void OnKeyDown(KeyEventArgs e)
+    {
+        base.OnKeyDown(e);
+        if (AllOverlays != null)
+        {
+            foreach (Overlay overlay in AllOverlays)
+            {
+                if (!overlay.IsVisible) continue;
+                overlay.KeyPressed(e);
+            }
+        }
+    }
+    
+    protected override void OnKeyUp(KeyEventArgs e)
+    {
+        base.OnKeyUp(e);
+        if (AllOverlays != null)
+        {
+            foreach (Overlay overlay in AllOverlays)
+            {
+                if (!overlay.IsVisible) continue;
+                overlay.KeyReleased(e);
+            }
+        }
+    }
+
     private OverlayPointerArgs ConstructPointerArgs(PointerEventArgs e)
     {
         return new OverlayPointerArgs

+ 62 - 0
tests/PixiEditor.Tests/EditableVectorPathTests.cs

@@ -332,6 +332,68 @@ public class EditableVectorPathTests
         Assert.Equal(new VecF(0, 0), editablePath.SubShapes[0].Points[4].Verb.To);
     }
 
+    [Fact]
+    public void TestThatMultiSubShapesWithUnclosedReturnsCorrectPoints()
+    {
+        VectorPath path = new VectorPath();
+        
+        path.LineTo(new VecF(2, 2));
+        path.LineTo(new VecF(0, 4));
+        
+        path.AddOval(RectD.FromCenterAndSize(new VecD(5, 5), new VecD(10, 10)));
+        
+        EditableVectorPath editablePath = new EditableVectorPath(path);
+        
+        Assert.Equal(2, editablePath.SubShapes.Count);
+        
+        Assert.Equal(3, editablePath.SubShapes[0].Points.Count);
+        Assert.Equal(new VecF(0, 0), editablePath.SubShapes[0].Points[0].Position);
+        Assert.Equal(new VecF(2, 2), editablePath.SubShapes[0].Points[1].Position);
+        Assert.Equal(new VecF(0, 4), editablePath.SubShapes[0].Points[2].Position);
+        
+        Assert.False(editablePath.SubShapes[0].IsClosed);
+        
+        Assert.Equal(4, editablePath.SubShapes[1].Points.Count);
+        Assert.Equal(new VecF(10, 5), editablePath.SubShapes[1].Points[0].Position);
+        Assert.Equal(new VecF(5, 10), editablePath.SubShapes[1].Points[1].Position);
+        Assert.Equal(new VecF(0, 5), editablePath.SubShapes[1].Points[2].Position);
+        Assert.Equal(new VecF(5, 0), editablePath.SubShapes[1].Points[3].Position);
+        
+        Assert.True(editablePath.SubShapes[1].IsClosed);
+    }
+    
+    [Fact]
+    public void TestThatMoveToProducesEmptyVerb()
+    {
+        VectorPath path = new VectorPath();
+        path.MoveTo(new VecF(0, 0));
+        
+        EditableVectorPath editablePath = new EditableVectorPath(path);
+        
+        Assert.Single(editablePath.SubShapes);
+        Assert.Single(editablePath.SubShapes[0].Points);
+        
+        Assert.Equal(null, editablePath.SubShapes[0].Points[0].Verb.VerbType);
+    }
+    
+    [Fact]
+    public void TestThatMultipleMoveToProduceEmptyVerbs()
+    {
+        VectorPath path = new VectorPath();
+        path.MoveTo(new VecF(0, 0));
+        path.MoveTo(new VecF(2, 2));
+        
+        EditableVectorPath editablePath = new EditableVectorPath(path);
+        
+        Assert.Equal(2, editablePath.SubShapes.Count);
+        
+        Assert.Single(editablePath.SubShapes[0].Points);
+        Assert.Single(editablePath.SubShapes[1].Points);
+        
+        Assert.Null(editablePath.SubShapes[0].Points[0].Verb.VerbType);
+        Assert.Null(editablePath.SubShapes[1].Points[0].Verb.VerbType);
+    }
+
     [Fact]
     public void TestThatEditingPointResultsInCorrectVectorPath()
     {