Browse Source

Snapping wip

flabbet 11 months ago
parent
commit
3b69114738

+ 3 - 0
src/PixiEditor.Zoombox/Zoombox.cs

@@ -125,6 +125,8 @@ public partial class Zoombox : ContentControl, INotifyPropertyChanged
         remove => RemoveHandler(ViewportMovedEvent, value);
     }
 
+    public event Action<double> ScaleChanged;
+
     public VecD CanvasPos => ToScreenSpace(VecD.Zero);
     public double CanvasX => ToScreenSpace(VecD.Zero).X;
     public double CanvasY => ToScreenSpace(VecD.Zero).Y;
@@ -293,6 +295,7 @@ public partial class Zoombox : ContentControl, INotifyPropertyChanged
     {
         VecD realDim = new VecD(Bounds.Width, Bounds.Height);
         RealDimensions = realDim;
+        ScaleChanged?.Invoke(Scale);
         RaiseEvent(new ViewportRoutedEventArgs(
             ViewportMovedEvent,
             Center,

+ 71 - 0
src/PixiEditor/Models/Controllers/InputDevice/SnappingController.cs

@@ -0,0 +1,71 @@
+using PixiEditor.Numerics;
+
+namespace PixiEditor.Models.Controllers.InputDevice;
+
+public class SnappingController
+{
+    public const double DefaultSnapDistance = 16;
+    
+    /// <summary>
+    ///     Minimum distance that object has to be from snap point to snap to it. Expressed in pixels.
+    /// </summary>
+    public double SnapDistance { get; set; } = DefaultSnapDistance;
+
+    public Dictionary<string, double> HorizontalSnapPoints { get; } = new();
+    public Dictionary<string, double> VerticalSnapPoints { get; } = new(); 
+    
+    
+    public double? SnapToHorizontal(double xPos)
+    {
+        if (HorizontalSnapPoints.Count == 0)
+        {
+            return null;
+        }
+
+        double closest = HorizontalSnapPoints.First().Value;
+        foreach (double snapPoint in HorizontalSnapPoints.Values)
+        {
+            if (Math.Abs(snapPoint - xPos) < Math.Abs(closest - xPos))
+            {
+                closest = snapPoint;
+            }
+        }
+        
+        if (Math.Abs(closest - xPos) > SnapDistance)
+        {
+            return null;
+        }
+
+        return closest;
+    }
+    
+    public double? SnapToVertical(double yPos)
+    {
+        if (VerticalSnapPoints.Count == 0)
+        {
+            return null;
+        }
+
+        double closest = VerticalSnapPoints.First().Value;
+        foreach (double snapPoint in VerticalSnapPoints.Values)
+        {
+            if (Math.Abs(snapPoint - yPos) < Math.Abs(closest - yPos))
+            {
+                closest = snapPoint;
+            }
+        }
+        
+        if (Math.Abs(closest - yPos) > SnapDistance)
+        {
+            return null;
+        }
+
+        return closest;
+    }
+
+    public void AddXYAxis(string identifier, VecD axisVector)
+    {
+        HorizontalSnapPoints[identifier] = axisVector.X;
+        VerticalSnapPoints[identifier] = axisVector.Y;
+    }
+}

+ 8 - 0
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -200,6 +200,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public VectorPath SelectionPathBindable => selectionPath;
     public ObservableCollection<PaletteColor> Swatches { get; set; } = new();
     public ObservableRangeCollection<PaletteColor> Palette { get; set; } = new();
+    public SnappingViewModel SnappingViewModel { get; }
     public DocumentTransformViewModel TransformViewModel { get; }
     public ReferenceLayerViewModel ReferenceLayerViewModel { get; }
     public LineToolOverlayViewModel LineToolOverlayViewModel { get; }
@@ -236,6 +237,13 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         LineToolOverlayViewModel = new();
         LineToolOverlayViewModel.LineMoved += (_, args) =>
             Internals.ChangeController.LineOverlayMovedInlet(args.Item1, args.Item2);
+        
+        SnappingViewModel = new();
+        SnappingViewModel.AddFromDocumentSize(SizeBindable);
+        SizeChanged += (_, args) =>
+        {
+            SnappingViewModel.AddFromDocumentSize(args.NewSize);
+        };
 
         VecI previewSize = StructureMemberViewModel.CalculatePreviewSize(SizeBindable);
         PreviewSurface = new Texture(new VecI(previewSize.X, previewSize.Y));

+ 21 - 0
src/PixiEditor/ViewModels/Document/SnappingViewModel.cs

@@ -0,0 +1,21 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Models.Controllers.InputDevice;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.ViewModels.Document;
+
+public class SnappingViewModel : PixiObservableObject
+{
+    public SnappingController SnappingController { get; } = new SnappingController();
+
+    public SnappingViewModel()
+    {
+        SnappingController.AddXYAxis("Root", VecD.Zero);
+    }
+    
+    public void AddFromDocumentSize(VecD documentSize)
+    {
+        SnappingController.AddXYAxis("DocumentSize", documentSize);
+        SnappingController.AddXYAxis("DocumentCenter", documentSize / 2);
+    }
+}

+ 7 - 0
src/PixiEditor/ViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs

@@ -103,6 +103,13 @@ internal class DocumentTransformViewModel : ObservableObject, ITransformHandler
         }
     }
     
+    private bool enableSnapping = true;
+    public bool EnableSnapping
+    {
+        get => enableSnapping;
+        set => SetProperty(ref enableSnapping, value);
+    }
+    
     private ExecutionTrigger<ShapeCorners> requestedCornersExecutor;
     public ExecutionTrigger<ShapeCorners> RequestCornersExecutor
     {

+ 1 - 0
src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -6,6 +6,7 @@ using PixiDocks.Core.Docking;
 using PixiDocks.Core.Docking.Events;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Helpers.UI;
+using PixiEditor.Models.Controllers.InputDevice;
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Numerics;
 using PixiEditor.ViewModels.Dock;

+ 1 - 0
src/PixiEditor/Views/Dock/DocumentTemplate.axaml

@@ -34,6 +34,7 @@
         FlipX="{Binding FlipX, Mode=TwoWay}"
         FlipY="{Binding FlipY, Mode=TwoWay}"
         Channels="{Binding Channels, Mode=TwoWay}"
+        SnappingController="{Binding ActiveDocument.SnappingViewModel.SnappingController, Source={viewModels1:MainVM DocumentManagerSVM}}"
         ContextRequested="Viewport_OnContextMenuOpening"
         Document="{Binding Document}">
         <viewportControls:Viewport.ContextFlyout>

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

@@ -93,6 +93,15 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
     public static readonly StyledProperty<bool> IsOverCanvasProperty = AvaloniaProperty.Register<Viewport, bool>(
         "IsOverCanvas");
 
+    public static readonly StyledProperty<SnappingController> SnappingControllerProperty = AvaloniaProperty.Register<Viewport, SnappingController>(
+        nameof(SnappingController));
+
+    public SnappingController SnappingController
+    {
+        get => GetValue(SnappingControllerProperty);
+        set => SetValue(SnappingControllerProperty, value);
+    }
+
     public bool IsOverCanvas
     {
         get => GetValue(IsOverCanvasProperty);
@@ -317,6 +326,12 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         
         Scene.PointerExited += (sender, args) => IsOverCanvas = false;
         Scene.PointerEntered += (sender, args) => IsOverCanvas = true;
+        Scene.ScaleChanged += OnScaleChanged;
+    }
+
+    private void OnScaleChanged(double newScale)
+    {
+        SnappingController.SnapDistance = SnappingController.DefaultSnapDistance / newScale;
     }
 
     private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)

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

@@ -218,6 +218,13 @@ internal class ViewportOverlays
             Path = "Document.TransformViewModel.TransformActive",
             Mode = BindingMode.OneWay
         };
+        
+        Binding snappingBinding = new()
+        {
+            Source = Viewport,
+            Path = "Document.SnappingViewModel.SnappingController",
+            Mode = BindingMode.OneWay
+        };
 
         Binding actionCompletedBinding = new()
         {
@@ -290,6 +297,7 @@ internal class ViewportOverlays
 
         transformOverlay.Bind(Visual.IsVisibleProperty, isVisibleBinding);
         transformOverlay.Bind(TransformOverlay.ActionCompletedProperty, actionCompletedBinding);
+        transformOverlay.Bind(TransformOverlay.SnappingControllerProperty, snappingBinding);
         transformOverlay.Bind(TransformOverlay.CornersProperty, cornersBinding);
         transformOverlay.Bind(TransformOverlay.RequestCornersExecutorProperty, requestedCornersBinding);
         transformOverlay.Bind(TransformOverlay.CornerFreedomProperty, cornerFreedomBinding);

+ 77 - 2
src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs

@@ -12,6 +12,7 @@ using PixiEditor.Helpers.Extensions;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Extensions.UI.Overlays;
 using PixiEditor.Helpers.UI;
+using PixiEditor.Models.Controllers.InputDevice;
 using PixiEditor.Numerics;
 using PixiEditor.Views.Overlays.Handles;
 using Point = Avalonia.Point;
@@ -110,6 +111,24 @@ internal class TransformOverlay : Overlay
         set => SetValue(ActionCompletedProperty, value);
     }
 
+    public static readonly StyledProperty<bool> SnappingEnabledProperty = AvaloniaProperty.Register<TransformOverlay, bool>(
+        nameof(SnappingEnabled), defaultValue: true);
+
+    public bool SnappingEnabled
+    {
+        get => GetValue(SnappingEnabledProperty);
+        set => SetValue(SnappingEnabledProperty, value);
+    }
+
+    public static readonly StyledProperty<SnappingController> SnappingControllerProperty = AvaloniaProperty.Register<TransformOverlay, SnappingController>(
+        nameof(SnappingController));
+
+    public SnappingController SnappingController
+    {
+        get => GetValue(SnappingControllerProperty);
+        set => SetValue(SnappingControllerProperty, value);
+    }
+
     static TransformOverlay()
     {
         AffectsRender<TransformOverlay>(CornersProperty, ZoomScaleProperty, SideFreedomProperty, CornerFreedomProperty, LockRotationProperty, SnapToAnglesProperty, InternalStateProperty, ZoomboxAngleProperty, CoverWholeScreenProperty);
@@ -459,15 +478,71 @@ internal class TransformOverlay : Overlay
         if (Corners.IsSnappedToPixels)
             delta = delta.Round();
 
-        Corners = new ShapeCorners()
+        ShapeCorners rawCorners = new ShapeCorners()
         {
             BottomLeft = cornersOnStartMove.BottomLeft + delta,
             BottomRight = cornersOnStartMove.BottomRight + delta,
             TopLeft = cornersOnStartMove.TopLeft + delta,
             TopRight = cornersOnStartMove.TopRight + delta,
         };
+        
+        VecD snapDelta = TrySnapCorners(rawCorners);
+        
+        Corners = new ShapeCorners()
+        {
+            BottomLeft = cornersOnStartMove.BottomLeft + delta + snapDelta,
+            BottomRight = cornersOnStartMove.BottomRight + delta + snapDelta,
+            TopLeft = cornersOnStartMove.TopLeft + delta + snapDelta,
+            TopRight = cornersOnStartMove.TopRight + delta + snapDelta,
+        };
+
+        InternalState = InternalState with { Origin = originOnStartMove + delta + snapDelta };
+    }
 
-        InternalState = InternalState with { Origin = originOnStartMove + delta };
+    private VecD TrySnapCorners(ShapeCorners rawCorners)
+    {
+        if (!SnappingEnabled || SnappingController is null)
+        {
+            return VecD.Zero;
+        }
+        
+        VecD[] pointsToTest = new VecD[]
+        {
+            rawCorners.RectCenter,
+            rawCorners.TopLeft, 
+            rawCorners.TopRight, 
+            rawCorners.BottomLeft, 
+            rawCorners.BottomRight
+        };
+        
+        VecD snapDelta = new();
+        bool hasXSnap = false;
+        bool hasYSnap = false;
+        
+        foreach (var point in pointsToTest)
+        {
+            double? snapX = SnappingController.SnapToHorizontal(point.X);
+            double? snapY = SnappingController.SnapToVertical(point.Y);
+            
+            if (snapX is not null && !hasXSnap)
+            {
+                snapDelta += new VecD(snapX.Value - point.X, 0);
+                hasXSnap = true;
+            }
+            
+            if (snapY is not null && !hasYSnap)
+            {
+                snapDelta += new VecD(0, snapY.Value - point.Y);
+                hasYSnap = true;
+            }
+            
+            if (hasXSnap && hasYSnap)
+            {
+                break;
+            }
+        }
+        
+        return snapDelta;
     }
 
     private Cursor HandleRotate(VecD pos)

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

@@ -104,6 +104,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         set => SetValue(ChannelsProperty, value);
     }
 
+
     private Bitmap? checkerBitmap;
 
     private Overlay? capturedOverlay;