Bladeren bron

Added stabilization modes and overlay wip

Krzysztof Krysiński 1 maand geleden
bovenliggende
commit
daade5e781

+ 6 - 1
src/PixiEditor/Data/Localization/Languages/en.json

@@ -1220,5 +1220,10 @@
   "PAN_POSITION": "Pan Position",
   "ZOOM": "Zoom",
   "FLIP_X": "Flip X",
-  "FLIP_Y": "Flip Y"
+  "FLIP_Y": "Flip Y",
+  "STABILIZATION_SETTING": "Stabilization",
+  "STABILIZATION_MODE_SETTING": "Stabilization Mode",
+  "NONE": "None",
+  "DISTANCE_BASED": "Distance Based",
+  "TIME_BASED": "Time Based"
 }

+ 3 - 3
src/PixiEditor/Models/Controllers/InputDevice/MouseInputFilter.cs

@@ -53,13 +53,13 @@ internal class MouseInputFilter
 
     public void DeactivatedInlet(object? sender, EventArgs e)
     {
-        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None);
+        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None, 1);
         MouseUpInlet(argsLeft);
         
-        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None);
+        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None, 1);
         MouseUpInlet(argsMiddle);
         
-        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None);
+        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, PointerType.Mouse, VecD.Zero, KeyModifiers.None, 0, PointerPointProperties.None, 1);
         MouseUpInlet(argsRight);
     }
 }

+ 4 - 1
src/PixiEditor/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs

@@ -1,6 +1,7 @@
 using Avalonia.Input;
 using Drawie.Backend.Core.Numerics;
 using Drawie.Numerics;
+using PixiEditor.Models.Position;
 
 namespace PixiEditor.Models.Controllers.InputDevice;
 
@@ -13,9 +14,10 @@ internal class MouseOnCanvasEventArgs : EventArgs
     public bool Handled { get; set; }
     public int ClickCount { get; set; } = 1;
     public PointerPointProperties Properties { get; }
+    public double ViewportScale { get; set; }
 
     public MouseOnCanvasEventArgs(MouseButton button, PointerType type, VecD positionOnCanvas, KeyModifiers keyModifiers, int clickCount,
-        PointerPointProperties properties)
+        PointerPointProperties properties, double viewportScale)
     {
         Button = button;
         PositionOnCanvas = positionOnCanvas;
@@ -23,5 +25,6 @@ internal class MouseOnCanvasEventArgs : EventArgs
         ClickCount = clickCount;
         Properties = properties;
         PointerType = type;
+        ViewportScale = viewportScale;
     }
 }

+ 51 - 10
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/BrushBasedExecutor.cs

@@ -40,7 +40,8 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
     private Guid brushOutputGuid = Guid.Empty;
     private BrushOutputNode? outputNode;
     private VecD lastSmoothed;
-    private Stopwatch stopwatch = new Stopwatch();
+    private DateTime lastTime;
+    private double lastViewportZoom = 1.0;
 
     protected IBrushToolHandler BrushTool;
     protected IBrushToolbar BrushToolbar;
@@ -50,7 +51,7 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
     protected Guid layerId;
     protected Color color;
     protected bool antiAliasing;
-    private bool firstApply = true;
+    protected bool firstApply = true;
 
     protected bool drawOnMask;
     public double ToolSize => BrushToolbar.ToolSize;
@@ -99,7 +100,13 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
 
     protected virtual void EnqueueDrawActions()
     {
-        IAction? action = new LineBasedPen_Action(layerId, GetStabilizedPoint(), (float)ToolSize,
+        var point = GetStabilizedPoint();
+        if (handler != null)
+        {
+            handler.LastAppliedPoint = point;
+        }
+
+        IAction? action = new LineBasedPen_Action(layerId, point, (float)ToolSize,
             antiAliasing, BrushData, drawOnMask,
             document!.AnimationHandler.ActiveFrameBindable, controller.LastPointerInfo, controller.LastKeyboardInfo,
             controller.EditorData);
@@ -109,21 +116,53 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
 
     protected VecD GetStabilizedPoint()
     {
-        float timeConstant = (float)BrushToolbar.Stabilization / 100f;
-        float elapsed = (float)stopwatch.Elapsed.TotalSeconds;
-        float alpha = elapsed / Math.Max(timeConstant + elapsed, 0.0001f);
-        VecD smoothed = lastSmoothed + (controller.LastPrecisePosition - lastSmoothed) * alpha;
-        stopwatch.Restart();
         if (firstApply)
         {
-            smoothed = controller.LastPrecisePosition;
+            lastSmoothed = controller.LastPrecisePosition;
+            lastTime = DateTime.Now;
+            firstApply = false;
+            return lastSmoothed;
         }
 
+        if (BrushToolbar.StabilizationMode == StabilizationMode.TimeBased)
+        {
+            return GetStabilizedPointTimeBased();
+        }
+
+        if (BrushToolbar.StabilizationMode == StabilizationMode.CircleRope)
+        {
+            return GetStabilizedPointCircleRope(lastViewportZoom);
+        }
+
+        return controller.LastPrecisePosition;
+    }
+
+    private VecD GetStabilizedPointTimeBased()
+    {
+        float timeConstant = (float)BrushToolbar.Stabilization / 100f;
+        float elapsed = (float)(DateTime.Now - lastTime).TotalSeconds;
+        float alpha = elapsed / Math.Max(timeConstant + elapsed, 0.0001f);
+        VecD smoothed = lastSmoothed + (controller.LastPrecisePosition - lastSmoothed) * alpha;
+        lastTime = DateTime.Now;
+
         lastSmoothed = smoothed;
-        firstApply = false;
         return smoothed;
     }
 
+    private VecD GetStabilizedPointCircleRope(double viewportZoom)
+    {
+        float radius = (float)BrushToolbar.Stabilization / (float)viewportZoom;
+        VecD direction = controller.LastPrecisePosition - lastSmoothed;
+        float distance = (float)direction.Length;
+
+        if (distance > radius)
+        {
+            direction = direction.Normalize();
+            lastSmoothed += direction * (distance - radius);
+        }
+
+        return lastSmoothed;
+    }
 
     private void UpdateBrushNodes()
     {
@@ -150,6 +189,7 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
         base.OnPrecisePositionChange(args);
         if (controller.LeftMousePressed)
         {
+            lastViewportZoom = args.ViewportScale;
             EnqueueDrawActions();
         }
     }
@@ -189,6 +229,7 @@ internal class BrushBasedExecutor : UpdateableChangeExecutor
 
     protected virtual void EnqueueEndDraw()
     {
+        firstApply = true;
         internals!.ActionAccumulator.AddFinishedActions(new EndLineBasedPen_Action());
     }
 

+ 11 - 3
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/PenToolExecutor.cs

@@ -43,11 +43,18 @@ internal class PenToolExecutor : BrushBasedExecutor<IPenToolHandler>
 
     protected override void EnqueueDrawActions()
     {
+        var point = GetStabilizedPoint();
+        if (handler != null)
+        {
+            handler.LastAppliedPoint = point;
+        }
+
         IAction? action = pixelPerfect switch
         {
-            false => new LineBasedPen_Action(layerId, GetStabilizedPoint(), (float)ToolSize,
+            false => new LineBasedPen_Action(layerId, point, (float)ToolSize,
                 antiAliasing, BrushData, drawOnMask,
-                document!.AnimationHandler.ActiveFrameBindable, controller.LastPointerInfo, controller.LastKeyboardInfo, controller.EditorData),
+                document!.AnimationHandler.ActiveFrameBindable, controller.LastPointerInfo, controller.LastKeyboardInfo,
+                controller.EditorData),
             true => new PixelPerfectPen_Action(layerId, controller!.LastPixelPosition, color, drawOnMask,
                 document!.AnimationHandler.ActiveFrameBindable)
         };
@@ -58,7 +65,7 @@ internal class PenToolExecutor : BrushBasedExecutor<IPenToolHandler>
     public override void OnSettingsChanged(string name, object value)
     {
         base.OnSettingsChanged(name, value);
-        if(name == nameof(IPenToolHandler.PixelPerfectEnabled) && value is bool bp)
+        if (name == nameof(IPenToolHandler.PixelPerfectEnabled) && value is bool bp)
         {
             EnqueueEndDraw();
             pixelPerfect = bp;
@@ -67,6 +74,7 @@ internal class PenToolExecutor : BrushBasedExecutor<IPenToolHandler>
 
     protected override void EnqueueEndDraw()
     {
+        firstApply = true;
         IAction? action = pixelPerfect switch
         {
             false => new EndLineBasedPen_Action(),

+ 15 - 2
src/PixiEditor/Models/Handlers/Toolbars/IBrushToolbar.cs

@@ -1,4 +1,6 @@
-using PixiEditor.ChangeableDocument.Changeables.Brushes;
+using System.ComponentModel;
+using PixiEditor.ChangeableDocument.Changeables.Brushes;
+using PixiEditor.Helpers;
 using PixiEditor.Models.BrushEngine;
 using PixiEditor.Views.Overlays.BrushShapeOverlay;
 
@@ -10,5 +12,16 @@ internal interface IBrushToolbar : IToolbar, IToolSizeToolbar
     public Brush Brush { get; set; }
     public BrushData CreateBrushData();
     public BrushData LastBrushData { get; }
-    public float Stabilization { get; set; }
+    public double Stabilization { get; set; }
+    public StabilizationMode StabilizationMode { get; set; }
+}
+
+public enum StabilizationMode
+{
+    [Description("NONE")]
+    None,
+    [Description("TIME_BASED")]
+    TimeBased,
+    [Description("DISTANCE_BASED")]
+    CircleRope
 }

+ 2 - 0
src/PixiEditor/Models/Handlers/Tools/IBrushToolHandler.cs

@@ -1,4 +1,5 @@
 using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
 using PixiEditor.Models.Input;
 
 namespace PixiEditor.Models.Handlers.Tools;
@@ -7,4 +8,5 @@ internal interface IBrushToolHandler : IToolHandler
 {
     public bool IsCustomBrushTool { get; }
     KeyCombination? DefaultShortcut { get; }
+    public VecD LastAppliedPoint { get; set; }
 }

+ 26 - 5
src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/BrushToolbar.cs

@@ -28,10 +28,16 @@ internal class BrushToolbar : Toolbar, IBrushToolbar
         set => GetSetting<BrushSettingViewModel>(nameof(Brush)).Value = value;
     }
 
-    public float Stabilization
+    public double Stabilization
     {
-        get => GetSetting<FloatSettingViewModel>(nameof(Stabilization)).Value;
-        set => GetSetting<FloatSettingViewModel>(nameof(Stabilization)).Value = value;
+        get => GetSetting<SizeSettingViewModel>(nameof(Stabilization)).Value;
+        set => GetSetting<SizeSettingViewModel>(nameof(Stabilization)).Value = value;
+    }
+
+    public StabilizationMode StabilizationMode
+    {
+        get => GetSetting<EnumSettingViewModel<StabilizationMode>>(nameof(StabilizationMode)).Value;
+        set => GetSetting<EnumSettingViewModel<StabilizationMode>>(nameof(StabilizationMode)).Value = value;
     }
 
     public BrushData CreateBrushData()
@@ -54,6 +60,10 @@ internal class BrushToolbar : Toolbar, IBrushToolbar
     public override void OnLoadedSettings()
     {
         OnPropertyChanged(nameof(ToolSize));
+        OnPropertyChanged(nameof(Brush));
+        OnPropertyChanged(nameof(AntiAliasing));
+        OnPropertyChanged(nameof(Stabilization));
+        OnPropertyChanged(nameof(StabilizationMode));
     }
 
     public BrushToolbar()
@@ -63,14 +73,25 @@ internal class BrushToolbar : Toolbar, IBrushToolbar
         setting.ValueChanged += (_, _) => OnPropertyChanged(nameof(ToolSize));
         AddSetting(setting);
         AddSetting(new BrushSettingViewModel(nameof(Brush), "BRUSH_SETTING") { IsExposed = true });
-        AddSetting(new FloatSettingViewModel(nameof(Stabilization), 0, "STABILIZATION_SETTING", min: 0, max: 15) { IsExposed = true });
+        AddSetting(new EnumSettingViewModel<StabilizationMode>(nameof(StabilizationMode), "STABILIZATION_MODE_SETTING") { IsExposed = true });
+        AddSetting(new SizeSettingViewModel(nameof(Stabilization), "STABILIZATION_SETTING", 0, min: 0, max: 128) { IsExposed = true });
 
         foreach (var aSetting in Settings)
         {
-            if (aSetting.Name == "Brush" || aSetting.Name == "AntiAliasing" || aSetting.Name == "ToolSize")
+            if (aSetting.Name is "Brush" or "AntiAliasing" or "ToolSize")
             {
                 aSetting.ValueChanged += SettingOnValueChanged;
             }
+
+            if(aSetting.Name == "Stabilization")
+            {
+                aSetting.ValueChanged += (_, _) => OnPropertyChanged(nameof(Stabilization));
+            }
+
+            if (aSetting.Name == "StabilizationMode")
+            {
+                aSetting.ValueChanged += (_, _) => OnPropertyChanged(nameof(StabilizationMode));
+            }
         }
     }
 

+ 8 - 0
src/PixiEditor/ViewModels/Tools/Tools/BrushBasedToolViewModel.cs

@@ -17,6 +17,8 @@ namespace PixiEditor.ViewModels.Tools.Tools;
 
 internal class BrushBasedToolViewModel : ToolViewModel, IBrushToolHandler
 {
+    private VecD lastPoint;
+
     private List<Setting> brushShapeSettings = new();
     public override Type[]? SupportedLayerTypes { get; } = { typeof(IRasterLayerHandler) };
     public override Type LayerTypeToCreateOnEmptyUse { get; } = typeof(ImageLayerNode);
@@ -27,6 +29,12 @@ internal class BrushBasedToolViewModel : ToolViewModel, IBrushToolHandler
     public bool IsCustomBrushTool { get; private set; }
     public KeyCombination? DefaultShortcut { get; set; }
 
+    public VecD LastAppliedPoint
+    {
+        get => lastPoint;
+        set => SetProperty(ref lastPoint, value);
+    }
+
     [Settings.Inherited] public double ToolSize => GetValue<double>();
 
     private string? toolName;

+ 3 - 3
src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs

@@ -562,7 +562,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         VecD scenePos = Scene.ToZoomboxSpace(new VecD(pos.X, pos.Y));
         MouseOnCanvasEventArgs? parameter =
             new MouseOnCanvasEventArgs(mouseButton, e.Pointer.Type, scenePos, e.KeyModifiers, e.ClickCount,
-                e.GetCurrentPoint(this).Properties);
+                e.GetCurrentPoint(this).Properties, Scene.Scale);
 
         if (MouseDownCommand.CanExecute(parameter))
             MouseDownCommand.Execute(parameter);
@@ -577,7 +577,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
         MouseButton mouseButton = e.GetMouseButton(this);
 
-        MouseOnCanvasEventArgs parameter = new(mouseButton, e.Pointer.Type, conv, e.KeyModifiers, 0, e.GetCurrentPoint(this).Properties);
+        MouseOnCanvasEventArgs parameter = new(mouseButton, e.Pointer.Type, conv, e.KeyModifiers, 0, e.GetCurrentPoint(this).Properties, Scene.Scale);
 
         if (MouseMoveCommand.CanExecute(parameter))
             MouseMoveCommand.Execute(parameter);
@@ -590,7 +590,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
         Point pos = e.GetPosition(Scene);
         VecD conv = Scene.ToZoomboxSpace(new VecD(pos.X, pos.Y));
-        MouseOnCanvasEventArgs parameter = new(e.InitialPressMouseButton, e.Pointer.Type, conv, e.KeyModifiers, 0, e.GetCurrentPoint(this).Properties);
+        MouseOnCanvasEventArgs parameter = new(e.InitialPressMouseButton, e.Pointer.Type, conv, e.KeyModifiers, 0, e.GetCurrentPoint(this).Properties, Scene.Scale);
         if (MouseUpCommand.CanExecute(parameter))
             MouseUpCommand.Execute(parameter);
     }

+ 20 - 0
src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs

@@ -482,10 +482,30 @@ internal class ViewportOverlays
             Source = ViewModelMain.Current, Path = "GetEditorData", Mode = BindingMode.OneWay
         };
 
+        Binding stabilizationModeBinding = new()
+        {
+            Source = ViewModelMain.Current.ToolsSubViewModel, Path = "ActiveBrushToolbar.StabilizationMode", Mode = BindingMode.OneWay
+        };
+
+        Binding stabilizationBinding = new()
+        {
+            Source = ViewModelMain.Current.ToolsSubViewModel, Path = "ActiveBrushToolbar.Stabilization", Mode = BindingMode.OneWay
+        };
+
+        Binding lastAppliedPointBinding = new()
+        {
+            Source = ViewModelMain.Current.ToolsSubViewModel,
+            Path = "ActiveTool.LastAppliedPoint",
+            Mode = BindingMode.OneWay
+        };
+
         brushShapeOverlay.Bind(Visual.IsVisibleProperty, isVisibleMultiBinding);
         brushShapeOverlay.Bind(BrushShapeOverlay.BrushDataProperty, brushDataBinding);
         brushShapeOverlay.Bind(BrushShapeOverlay.ActiveFrameTimeProperty, activeFrameTimeBidning);
         brushShapeOverlay.Bind(BrushShapeOverlay.EditorDataProperty, editorDataBinding);
+        brushShapeOverlay.Bind(BrushShapeOverlay.StabilizationModeProperty, stabilizationModeBinding);
+        brushShapeOverlay.Bind(BrushShapeOverlay.StabilizationProperty, stabilizationBinding);
+        brushShapeOverlay.Bind(BrushShapeOverlay.LastAppliedPointProperty, lastAppliedPointBinding);
     }
 
     private void BindTextOverlay()

+ 111 - 13
src/PixiEditor/Views/Overlays/BrushShapeOverlay/BrushShapeOverlay.cs

@@ -1,5 +1,6 @@
 using Avalonia;
 using Avalonia.Input;
+using Avalonia.Media;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -9,10 +10,14 @@ using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Brushes;
 using PixiEditor.ChangeableDocument.Rendering.ContextData;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Handlers.Toolbars;
 using PixiEditor.UI.Common.Extensions;
 using PixiEditor.Views.Rendering;
 using Canvas = Drawie.Backend.Core.Surfaces.Canvas;
 using Colors = Drawie.Backend.Core.ColorsImpl.Colors;
+using IBrush = Avalonia.Media.IBrush;
 
 namespace PixiEditor.Views.Overlays.BrushShapeOverlay;
 #nullable enable
@@ -33,6 +38,36 @@ internal class BrushShapeOverlay : Overlay
         AvaloniaProperty.Register<BrushShapeOverlay, Func<EditorData>>(
             nameof(EditorData));
 
+    public static readonly StyledProperty<StabilizationMode> StabilizationModeProperty =
+        AvaloniaProperty.Register<BrushShapeOverlay, StabilizationMode>(
+            nameof(StabilizationMode));
+
+    public static readonly StyledProperty<double> StabilizationProperty =
+        AvaloniaProperty.Register<BrushShapeOverlay, double>(
+            nameof(Stabilization));
+
+    public static readonly StyledProperty<VecD> LastAppliedPointProperty =
+        AvaloniaProperty.Register<BrushShapeOverlay, VecD>(
+            nameof(LastAppliedPoint));
+
+    public VecD LastAppliedPoint
+    {
+        get => GetValue(LastAppliedPointProperty);
+        set => SetValue(LastAppliedPointProperty, value);
+    }
+
+    public double Stabilization
+    {
+        get => GetValue(StabilizationProperty);
+        set => SetValue(StabilizationProperty, value);
+    }
+
+    public StabilizationMode StabilizationMode
+    {
+        get => GetValue(StabilizationModeProperty);
+        set => SetValue(StabilizationModeProperty, value);
+    }
+
     public Func<EditorData> EditorData
     {
         get => GetValue(EditorDataProperty);
@@ -69,12 +104,17 @@ internal class BrushShapeOverlay : Overlay
 
     private Paint paint = new Paint() { Color = Colors.LightGray, StrokeWidth = 1, Style = PaintStyle.Stroke };
 
+    private VecD lastPoint;
     private VecD lastDirCalculationPoint;
     private float lastSize;
+    private bool isMouseDown;
     private PointerInfo lastPointerInfo;
 
     private ChangeableDocument.Changeables.Brushes.BrushEngine engine = new();
 
+    private Drawie.Backend.Core.ColorsImpl.Color ropeColor;
+    private Drawie.Backend.Core.ColorsImpl.Color pointColor;
+
     static BrushShapeOverlay()
     {
         AffectsOverlayRender(BrushShapeProperty, BrushDataProperty, ActiveFrameTimeProperty, EditorDataProperty);
@@ -86,6 +126,9 @@ internal class BrushShapeOverlay : Overlay
     public BrushShapeOverlay()
     {
         IsHitTestVisible = false;
+        AlwaysPassPointerEvents = true;
+        ropeColor = ResourceLoader.GetResource<Color>("ErrorOnDarkColor").ToColor();
+        pointColor = ResourceLoader.GetResource<Color>("ThemeAccent3Color").ToColor();
     }
 
     private static void UpdateBrush(AvaloniaPropertyChangedEventArgs args)
@@ -110,7 +153,8 @@ internal class BrushShapeOverlay : Overlay
         PointerInfo pointer = new PointerInfo(pos, 1, 0, VecD.Zero, dirNormalized);
 
         engine.ExecuteBrush(null, BrushData, pos, ActiveFrameTime,
-            ColorSpace.CreateSrgb(), SamplingOptions.Default, pointer, new KeyboardInfo(), EditorData?.Invoke() ?? new EditorData(Colors.White, Colors.Black));
+            ColorSpace.CreateSrgb(), SamplingOptions.Default, pointer, new KeyboardInfo(),
+            EditorData?.Invoke() ?? new EditorData(Colors.White, Colors.Black));
     }
 
     protected override void OnOverlayPointerMoved(OverlayPointerArgs args)
@@ -121,10 +165,21 @@ internal class BrushShapeOverlay : Overlay
         }
 
         UpdateBrushShape(args.Point);
+        lastPoint = args.Point;
 
         Refresh();
     }
 
+    protected override void OnOverlayPointerPressed(OverlayPointerArgs args)
+    {
+        isMouseDown = true;
+    }
+
+    protected override void OnOverlayPointerReleased(OverlayPointerArgs args)
+    {
+        isMouseDown = false;
+    }
+
     protected override void OnKeyPressed(KeyEventArgs args)
     {
         UpdateBrushShape(lastDirCalculationPoint);
@@ -146,23 +201,32 @@ internal class BrushShapeOverlay : Overlay
         {
             paint.IsAntiAliased = true;
             targetCanvas.Save();
-            /*using var path = new VectorPath(BrushShape);
-            var rect = new RectD(lastMousePos - new VecD((BrushSize / 2f)), new VecD(BrushSize));
 
-            path.Offset(rect.Center - path.Bounds.Center);
-
-            VecD scale = new VecD(rect.Size.X / (float)path.Bounds.Width, rect.Size.Y / (float)path.Bounds.Height);
-            if (scale.IsNaNOrInfinity())
+            if (isMouseDown)
             {
-                scale = VecD.Zero;
+                if (StabilizationMode == StabilizationMode.CircleRope)
+                {
+                    float radius = (float)Stabilization / (float)ZoomScale;
+                    paint.Style = PaintStyle.Stroke;
+
+                    paint.Color = pointColor;
+                    targetCanvas.DrawCircle(LastAppliedPoint, 5f / (float)ZoomScale, paint);
+
+                    paint.Color = ropeColor;
+
+                    DrawConstrainedRope(targetCanvas, lastPoint, LastAppliedPoint, radius, paint);
+
+                    paint.Color = pointColor;
+                    targetCanvas.DrawCircle(lastPoint, 5f / (float)ZoomScale, paint);
+                }
             }
 
-            VecD uniformScale = new VecD(Math.Min(scale.X, scale.Y));
-            path.Transform(Matrix3X3.CreateScale((float)uniformScale.X, (float)uniformScale.Y, (float)rect.Center.X,
-                (float)rect.Center.Y));
+            if (StabilizationMode == StabilizationMode.None)
+            {
+                paint.Color = Colors.LightGray;
+                targetCanvas.DrawPath(BrushShape, paint);
+            }
 
-            */
-            targetCanvas.DrawPath(BrushShape, paint);
             targetCanvas.Restore();
         }
     }
@@ -171,4 +235,38 @@ internal class BrushShapeOverlay : Overlay
     {
         paint.StrokeWidth = (float)(1.0f / newZoom);
     }
+
+    void DrawConstrainedRope(Canvas targetCanvas, VecD A, VecD B, double radius, Paint paint)
+    {
+        var AB = B - A;
+        double d = AB.Length;
+
+        using var path = new VectorPath();
+
+        if (d >= radius || d <= 1e-9)
+        {
+            path.MoveTo((VecF)A);
+            path.LineTo((VecF)B);
+            targetCanvas.DrawPath(path, paint);
+            return;
+        }
+
+        var dir = AB.Normalize();
+        var perp = new VecD(-dir.Y, dir.X);
+        var mid = (A + B) * 0.5;
+
+        // compute perpendicular offset so total rope length = radius
+        double halfD = d / 2.0;
+        double halfR = radius / 2.0;
+        double h = Math.Sqrt(Math.Max(0.0, halfR * halfR - halfD * halfD));
+
+        VecD P = mid + perp * h;
+
+        VecD c1 = A + (P - A) * 0.5;
+        VecD c2 = B + (P - B) * 0.5;
+
+        path.MoveTo((VecF)A);
+        path.CubicTo((VecF)c1, (VecF)c2, (VecF)B);
+        targetCanvas.DrawPath(path, paint);
+    }
 }

+ 2 - 0
src/PixiEditor/Views/Overlays/Overlay.cs

@@ -69,6 +69,8 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
         }
     }
 
+    public bool AlwaysPassPointerEvents { get; set; }
+
     private readonly Dictionary<AvaloniaProperty, OverlayTransition> activeTransitions = new();
 
     private DispatcherTimer? transitionTimer;

+ 1 - 1
src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs

@@ -667,7 +667,7 @@ internal class TransformOverlay : Overlay
 
         if (!isRotating && !actuallyMoved && pressedWithinBounds)
         {
-            MouseOnCanvasEventArgs args = new(MouseButton.Left, e.Pointer.Type, e.Point, e.Modifiers, lastClickCount, e.Properties);
+            MouseOnCanvasEventArgs args = new(MouseButton.Left, e.Pointer.Type, e.Point, e.Modifiers, lastClickCount, e.Properties, ZoomScale);
             PassthroughPointerPressedCommand?.Execute(args);
             lastClickCount = 0;
         }

+ 3 - 3
src/PixiEditor/Views/Rendering/Scene.cs

@@ -365,7 +365,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         {
             if(Document == null || Document.SceneTextures.TryGetValue(ViewportId, out var tex) == false)
                 return;
-            
+
             bool hasSaved = false;
             int saved = -1;
 
@@ -615,7 +615,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
                         if (args.Handled) break;
                         if (!overlay.IsVisible) continue;
 
-                        if (!overlay.IsHitTestVisible || !overlay.TestHit(args.Point)) continue;
+                        if ((!overlay.IsHitTestVisible || !overlay.TestHit(args.Point)) && !overlay.AlwaysPassPointerEvents) continue;
 
                         overlay.PressPointer(args);
                     }
@@ -681,7 +681,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
                         if (args.Handled) break;
                         if (!overlay.IsVisible) continue;
 
-                        if (!overlay.IsHitTestVisible || !overlay.TestHit(args.Point)) continue;
+                        if ((!overlay.IsHitTestVisible || !overlay.TestHit(args.Point)) && !overlay.AlwaysPassPointerEvents) continue;
 
                         overlay.ReleasePointer(args);
                     }