diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..787b44f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/App.xaml.cs b/App.xaml.cs index e597ea3..fc4f826 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -21,17 +21,27 @@ * */ +using LunaDraw.Logic.Utils; + namespace LunaDraw; public partial class App : Application { - public App() - { - InitializeComponent(); - } + public App(IPreferencesFacade preferencesFacade) + { + InitializeComponent(); + + var theme = preferencesFacade.Get(AppPreference.AppTheme); + UserAppTheme = theme switch + { + "Light" => AppTheme.Light, + "Dark" => AppTheme.Dark, + _ => AppTheme.Unspecified + }; + } - protected override Window CreateWindow(IActivationState? activationState) - { - return new Window(new AppShell()); - } + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new AppShell()); + } } \ No newline at end of file diff --git a/AppShell.xaml b/AppShell.xaml index e290a30..e973e1a 100644 --- a/AppShell.xaml +++ b/AppShell.xaml @@ -4,7 +4,8 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:pages="clr-namespace:LunaDraw.Pages" - Title="LunaDraw"> + Title="LunaDraw" + Shell.BackgroundColor="Transparent"> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Components/LayerControlView.xaml.cs b/Components/LayerControlView.xaml.cs index 5663615..79664b6 100644 --- a/Components/LayerControlView.xaml.cs +++ b/Components/LayerControlView.xaml.cs @@ -24,68 +24,69 @@ using LunaDraw.Logic.Models; using LunaDraw.Logic.ViewModels; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public partial class LayerControlView : ContentView { - public partial class LayerControlView : ContentView - { - public static readonly BindableProperty IsLayerPanelExpandedProperty = - BindableProperty.Create(nameof(IsLayerPanelExpanded), typeof(bool), typeof(LayerControlView), false, propertyChanged: OnIsLayerPanelExpandedChanged); + public MainViewModel? ViewModel => BindingContext as MainViewModel; - public bool IsLayerPanelExpanded - { - get => (bool)GetValue(IsLayerPanelExpandedProperty); - set => SetValue(IsLayerPanelExpandedProperty, value); - } + public static readonly BindableProperty IsLayerPanelExpandedProperty = + BindableProperty.Create(nameof(IsLayerPanelExpanded), typeof(bool), typeof(LayerControlView), false, propertyChanged: OnIsLayerPanelExpandedChanged); - public List MaskingModes { get; } = Enum.GetValues().Cast().ToList(); + public bool IsLayerPanelExpanded + { + get => (bool)GetValue(IsLayerPanelExpandedProperty); + set => SetValue(IsLayerPanelExpandedProperty, value); + } - public LayerControlView() - { - InitializeComponent(); - } + public List MaskingModes { get; } = Enum.GetValues().Cast().ToList(); - private static void OnIsLayerPanelExpandedChanged(BindableObject bindable, object oldValue, object newValue) - { - var control = (LayerControlView)bindable; - control.ContentGrid.IsVisible = (bool)newValue; - control.CollapseButton.Text = (bool)newValue ? "▼" : "▶"; - } + public LayerControlView() + { + InitializeComponent(); + } - private void OnCollapseClicked(object sender, EventArgs e) - { - IsLayerPanelExpanded = !IsLayerPanelExpanded; - } + private static void OnIsLayerPanelExpandedChanged(BindableObject bindable, object oldValue, object newValue) + { + var control = (LayerControlView)bindable; + control.ContentGrid.IsVisible = (bool)newValue; + control.CollapseButton.Text = (bool)newValue ? "▼" : "▶"; + } - private void OnDragStarting(object sender, DragStartingEventArgs e) - { - if (sender is Element element && element.BindingContext is Layer layer) - { - e.Data.Properties["SourceLayer"] = layer; - // Ensure the dragged layer is selected - if (this.BindingContext is MainViewModel viewModel) - { - viewModel.CurrentLayer = layer; - } - } - } + private void OnCollapseClicked(object sender, EventArgs e) + { + IsLayerPanelExpanded = !IsLayerPanelExpanded; + } - private void OnDragOver(object sender, DragEventArgs e) - { - e.AcceptedOperation = DataPackageOperation.Copy; - } + private void OnDragStarting(object sender, DragStartingEventArgs e) + { + if (sender is Element element && element.BindingContext is Layer layer) + { + e.Data.Properties["SourceLayer"] = layer; + // Ensure the dragged layer is selected + if (this.BindingContext is MainViewModel viewModel) + { + viewModel.CurrentLayer = layer; + } + } + } - private void OnDrop(object sender, DropEventArgs e) + private void OnDragOver(object sender, DragEventArgs e) + { + e.AcceptedOperation = DataPackageOperation.Copy; + } + + private void OnDrop(object sender, DropEventArgs e) + { + if (e.Data.Properties.TryGetValue("SourceLayer", out var sourceObj) && sourceObj is Layer sourceLayer) + { + if (sender is Element element && element.BindingContext is Layer targetLayer) + { + if (this.BindingContext is MainViewModel viewModel) { - if (e.Data.Properties.TryGetValue("SourceLayer", out var sourceObj) && sourceObj is Layer sourceLayer) - { - if (sender is Element element && element.BindingContext is Layer targetLayer) - { - if (this.BindingContext is MainViewModel viewModel) - { - viewModel.ReorderLayer(sourceLayer, targetLayer); - } - } - } + viewModel.ReorderLayer(sourceLayer, targetLayer); } + } } + } } diff --git a/Components/MiniMapView.xaml b/Components/MiniMapView.xaml index e17521c..825e120 100644 --- a/Components/MiniMapView.xaml +++ b/Components/MiniMapView.xaml @@ -3,11 +3,11 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:skiasharp="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls" x:Class="LunaDraw.Components.MiniMapView"> - - - + + + diff --git a/Components/MiniMapView.xaml.cs b/Components/MiniMapView.xaml.cs index 959b758..04e0d1c 100644 --- a/Components/MiniMapView.xaml.cs +++ b/Components/MiniMapView.xaml.cs @@ -23,7 +23,9 @@ using System.Reactive.Linq; +using LunaDraw.Logic.Extensions; using LunaDraw.Logic.Messages; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.ViewModels; using ReactiveUI; @@ -32,196 +34,191 @@ using SkiaSharp.Views.Maui; using SkiaSharp.Views.Maui.Controls; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public partial class MiniMapView : ContentView { - public partial class MiniMapView : ContentView + private readonly IMessageBus? messageBus; + private readonly IPreferencesFacade? preferencesFacade; + private MainViewModel? viewModel; + private SKMatrix fitMatrix; + private float density = 1.0f; + + public MiniMapView() { - private MainViewModel? viewModel; - private SKMatrix fitMatrix; - private float density = 1.0f; + InitializeComponent(); - private IMessageBus? messageBus; - private IMessageBus? MessageBus + Loaded += (s, e) => { - get - { - if (messageBus != null) return messageBus; - messageBus = Handler?.MauiContext?.Services.GetService() - ?? IPlatformApplication.Current?.Services.GetService(); - return messageBus; - } - } + messageBus?.Listen() + .Throttle(TimeSpan.FromMilliseconds(30), RxApp.MainThreadScheduler) + .Subscribe(_ => miniMapCanvas?.InvalidateSurface()); + }; + + this.messageBus = Handler?.MauiContext?.Services.GetService() + ?? IPlatformApplication.Current?.Services.GetService(); + this.preferencesFacade = Handler?.MauiContext?.Services.GetService() + ?? IPlatformApplication.Current?.Services.GetService(); + } - public MiniMapView() - { - InitializeComponent(); + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + viewModel = BindingContext as MainViewModel; + miniMapCanvas?.InvalidateSurface(); + } - this.Loaded += (s, e) => - { - MessageBus?.Listen() - .Throttle(TimeSpan.FromMilliseconds(30), RxApp.MainThreadScheduler) - .Subscribe(_ => miniMapCanvas?.InvalidateSurface()); - }; - } + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + if (viewModel == null) return; - protected override void OnBindingContextChanged() - { - base.OnBindingContextChanged(); - viewModel = BindingContext as MainViewModel; - miniMapCanvas?.InvalidateSurface(); - } + var canvas = e.Surface.Canvas; + var info = e.Info; - private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + if (sender is SKCanvasView view && view.Width > 0) { - if (viewModel == null) return; - - var canvas = e.Surface.Canvas; - var info = e.Info; - - if (sender is SKCanvasView view && view.Width > 0) - { - density = (float)(info.Width / view.Width); - } + density = (float)(info.Width / view.Width); + } - canvas.Clear(SKColors.White); + var bgColor = preferencesFacade?.GetCanvasBackgroundColor() ?? SKColors.White; + canvas.Clear(bgColor); - // Calculate bounds of all elements - var contentBounds = SKRect.Empty; - bool hasContent = false; + // Calculate bounds of all elements + var contentBounds = SKRect.Empty; + bool hasContent = false; - foreach (var layer in viewModel.Layers) + foreach (var layer in viewModel.Layers) + { + if (!layer.IsVisible) continue; + foreach (var element in layer.Elements) { - if (!layer.IsVisible) continue; - foreach (var element in layer.Elements) + if (!element.IsVisible) continue; + var b = element.Bounds; + if (hasContent) + contentBounds.Union(b); + else { - if (!element.IsVisible) continue; - var b = element.Bounds; - if (hasContent) - contentBounds.Union(b); - else - { - contentBounds = b; - hasContent = true; - } + contentBounds = b; + hasContent = true; } } + } - if (!hasContent) - { - contentBounds = new SKRect(0, 0, 1000, 1000); - } + if (!hasContent) + { + contentBounds = new SKRect(0, 0, 1000, 1000); + } - contentBounds.Inflate(50, 50); + contentBounds.Inflate(50, 50); - // Calculate fit matrix (world to minimap) - float scaleX = info.Width / contentBounds.Width; - float scaleY = info.Height / contentBounds.Height; - float scale = Math.Min(scaleX, scaleY); + // Calculate fit matrix (world to minimap) + float scaleX = info.Width / contentBounds.Width; + float scaleY = info.Height / contentBounds.Height; + float scale = Math.Min(scaleX, scaleY); - float tx = (info.Width - contentBounds.Width * scale) / 2 - contentBounds.Left * scale; - float ty = (info.Height - contentBounds.Height * scale) / 2 - contentBounds.Top * scale; + float tx = (info.Width - contentBounds.Width * scale) / 2 - contentBounds.Left * scale; + float ty = (info.Height - contentBounds.Height * scale) / 2 - contentBounds.Top * scale; - fitMatrix = SKMatrix.CreateScale(scale, scale); - fitMatrix = SKMatrix.Concat(SKMatrix.CreateTranslation(tx, ty), fitMatrix); + fitMatrix = SKMatrix.CreateScale(scale, scale); + fitMatrix = SKMatrix.Concat(SKMatrix.CreateTranslation(tx, ty), fitMatrix); - // Draw content - canvas.Save(); - canvas.Concat(fitMatrix); + // Draw content + canvas.Save(); + canvas.Concat(fitMatrix); - foreach (var layer in viewModel.Layers) + foreach (var layer in viewModel.Layers) + { + if (layer.IsVisible) { - if (layer.IsVisible) + foreach (var element in layer.Elements) { - foreach (var element in layer.Elements) + if (element.IsVisible) { - if (element.IsVisible) - { - element.Draw(canvas); - } + element.Draw(canvas); } } } - canvas.Restore(); + } + canvas.Restore(); - // Draw viewport indicator - if (viewModel.NavigationModel.ViewMatrix.TryInvert(out var mainInverse)) + // Draw viewport indicator + if (viewModel.NavigationModel.ViewMatrix.TryInvert(out var mainInverse)) + { + var mainScreenRect = viewModel.CanvasSize; + if (mainScreenRect.Width > 0) { - var mainScreenRect = viewModel.CanvasSize; - if (mainScreenRect.Width > 0) + // Map screen corners to world points + var tl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Top)); + var tr = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Top)); + var br = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Bottom)); + var bl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Bottom)); + + // Map world points to minimap points + var mTl = fitMatrix.MapPoint(tl); + var mTr = fitMatrix.MapPoint(tr); + var mBr = fitMatrix.MapPoint(br); + var mBl = fitMatrix.MapPoint(bl); + + using var path = new SKPath(); + path.MoveTo(mTl); + path.LineTo(mTr); + path.LineTo(mBr); + path.LineTo(mBl); + path.Close(); + + using var paint = new SKPaint { - // Map screen corners to world points - var tl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Top)); - var tr = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Top)); - var br = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Bottom)); - var bl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Bottom)); - - // Map world points to minimap points - var mTl = fitMatrix.MapPoint(tl); - var mTr = fitMatrix.MapPoint(tr); - var mBr = fitMatrix.MapPoint(br); - var mBl = fitMatrix.MapPoint(bl); - - using var path = new SKPath(); - path.MoveTo(mTl); - path.LineTo(mTr); - path.LineTo(mBr); - path.LineTo(mBl); - path.Close(); - - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.Red, - StrokeWidth = 2, - IsAntialias = true - }; - canvas.DrawPath(path, paint); - - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = SKColors.Red.WithAlpha(50) - }; - canvas.DrawPath(path, fillPaint); - } + Style = SKPaintStyle.Stroke, + Color = SKColors.Red, + StrokeWidth = 2, + IsAntialias = true + }; + canvas.DrawPath(path, paint); + + using var fillPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + Color = SKColors.Red.WithAlpha(50) + }; + canvas.DrawPath(path, fillPaint); } } + } - private void OnTouch(object? sender, SKTouchEventArgs e) - { - if (viewModel == null) return; + private void OnTouch(object? sender, SKTouchEventArgs e) + { + if (viewModel == null) return; - if (!e.InContact) return; + if (!e.InContact) return; - var canvasView = sender as SKCanvasView; - if (canvasView == null) return; + var canvasView = sender as SKCanvasView; + if (canvasView == null) return; - switch (e.ActionType) - { - case SKTouchAction.Pressed: - case SKTouchAction.Moved: - var touchPointPixels = e.Location; + switch (e.ActionType) + { + case SKTouchAction.Pressed: + case SKTouchAction.Moved: + var touchPointPixels = e.Location; - if (fitMatrix.TryInvert(out var inverseFit)) - { - var worldPoint = inverseFit.MapPoint(touchPointPixels); + if (fitMatrix.TryInvert(out var inverseFit)) + { + var worldPoint = inverseFit.MapPoint(touchPointPixels); - // Calculate where this world point currently appears on screen - var currentViewPoint = viewModel.NavigationModel.ViewMatrix.MapPoint(worldPoint); - var screenCenter = new SKPoint(viewModel.CanvasSize.Width / 2, viewModel.CanvasSize.Height / 2); + // Calculate where this world point currently appears on screen + var currentViewPoint = viewModel.NavigationModel.ViewMatrix.MapPoint(worldPoint); + var screenCenter = new SKPoint(viewModel.CanvasSize.Width / 2, viewModel.CanvasSize.Height / 2); - // Calculate the delta to center it - var delta = screenCenter - currentViewPoint; + // Calculate the delta to center it + var delta = screenCenter - currentViewPoint; - // Apply translation to view matrix - var translation = SKMatrix.CreateTranslation(delta.X, delta.Y); - viewModel.NavigationModel.ViewMatrix = viewModel.NavigationModel.ViewMatrix.PostConcat(translation); + // Apply translation to view matrix + var translation = SKMatrix.CreateTranslation(delta.X, delta.Y); + viewModel.NavigationModel.ViewMatrix = viewModel.NavigationModel.ViewMatrix.PostConcat(translation); - MessageBus?.SendMessage(new CanvasInvalidateMessage()); - } - e.Handled = true; - break; - } + messageBus?.SendMessage(new CanvasInvalidateMessage()); + } + e.Handled = true; + break; } } } \ No newline at end of file diff --git a/Components/SettingsFlyoutPanel.xaml b/Components/SettingsFlyoutPanel.xaml index 29fd1f6..13767b2 100644 --- a/Components/SettingsFlyoutPanel.xaml +++ b/Components/SettingsFlyoutPanel.xaml @@ -3,227 +3,228 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:controls="clr-namespace:Maui.ColorPicker;assembly=Maui.ColorPicker" x:Class="LunaDraw.Components.SettingsFlyoutPanel"> - - - - diff --git a/Converters/ActiveShapeIconConverter.cs b/Converters/ActiveShapeIconConverter.cs index 188a52e..82f0d51 100644 --- a/Converters/ActiveShapeIconConverter.cs +++ b/Converters/ActiveShapeIconConverter.cs @@ -28,23 +28,23 @@ namespace LunaDraw.Converters; public class ActiveShapeIconConverter : IValueConverter { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is IDrawingTool tool) { - if (value is IDrawingTool tool) - { - if (tool is RectangleTool) return "▭"; - if (tool is EllipseTool) return "◯"; - if (tool is LineTool) return "/"; - } - - // Default icon for the shapes button if no specific shape tool is active, - // or if the active tool is not a shape (though the text binding usually only matters when it IS a shape, - // or if we want to revert to the default group icon) - return "🔷"; + if (tool is RectangleTool) return "▭"; + if (tool is EllipseTool) return "◯"; + if (tool is LineTool) return "/"; } - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } + // Default icon for the shapes button if no specific shape tool is active, + // or if the active tool is not a shape (though the text binding usually only matters when it IS a shape, + // or if we want to revert to the default group icon) + return "🔷"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } } diff --git a/Converters/BoolToEyeIconConverter.cs b/Converters/BoolToEyeIconConverter.cs index c5a5cdf..3c91d45 100644 --- a/Converters/BoolToEyeIconConverter.cs +++ b/Converters/BoolToEyeIconConverter.cs @@ -23,22 +23,21 @@ using System.Globalization; -namespace LunaDraw.Converters +namespace LunaDraw.Converters; + +public class BoolToEyeIconConverter : IValueConverter { - public class BoolToEyeIconConverter : IValueConverter + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isVisible) { - public object Convert(object ?value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isVisible) - { - return isVisible ? "👁" : "○"; // Eye vs Empty Circle - } - return "○"; - } - - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return value is string str && str == "👁"; - } + return isVisible ? "👁" : "○"; // Eye vs Empty Circle } + return "○"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is string str && str == "👁"; + } } \ No newline at end of file diff --git a/Converters/BoolToLayerPanelWidthConverter.cs b/Converters/BoolToLayerPanelWidthConverter.cs index bbf3636..4d57f7c 100644 --- a/Converters/BoolToLayerPanelWidthConverter.cs +++ b/Converters/BoolToLayerPanelWidthConverter.cs @@ -23,23 +23,22 @@ using System.Globalization; -namespace LunaDraw.Converters +namespace LunaDraw.Converters; + +public class BoolToLayerPanelWidthConverter : IValueConverter { - public class BoolToLayerPanelWidthConverter : IValueConverter + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isExpanded) { - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isExpanded) - { - return isExpanded ? 300.0 : 120.0; - } + return isExpanded ? 350.0 : 150.0; + } - return 300.0; - } + return 350.0; + } - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return 300.0; - } - } + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return 350.0; + } } diff --git a/Converters/BoolToLockIconConverter.cs b/Converters/BoolToLockIconConverter.cs index 3d2d5e5..9e8da3e 100644 --- a/Converters/BoolToLockIconConverter.cs +++ b/Converters/BoolToLockIconConverter.cs @@ -23,22 +23,21 @@ using System.Globalization; -namespace LunaDraw.Converters +namespace LunaDraw.Converters; + +public class BoolToLockIconConverter : IValueConverter { - public class BoolToLockIconConverter : IValueConverter + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isLocked) { - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isLocked) - { - return isLocked ? "🔒" : "🔓"; - } - return "🔓"; - } - - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } + return isLocked ? "🔒" : "🔓"; } + return "🔓"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/Converters/ColorToHexConverter.cs b/Converters/ColorToHexConverter.cs index 59228fa..c30bc26 100644 --- a/Converters/ColorToHexConverter.cs +++ b/Converters/ColorToHexConverter.cs @@ -24,47 +24,46 @@ using SkiaSharp; using System.Globalization; -namespace LunaDraw.Converters +namespace LunaDraw.Converters; + +public class ColorToHexConverter : IValueConverter { - public class ColorToHexConverter : IValueConverter + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null) return string.Empty; + + if (value is SKColor color) { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value == null) return string.Empty; + return $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}"; + } - if (value is SKColor color) - { - return $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}"; - } + return string.Empty; + } - return string.Empty; - } + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hexString && !string.IsNullOrWhiteSpace(hexString)) + { + hexString = hexString.TrimStart('#'); - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + if (hexString.Length == 6) + { + try { - if (value is string hexString && !string.IsNullOrWhiteSpace(hexString)) - { - hexString = hexString.TrimStart('#'); - - if (hexString.Length == 6) - { - try - { - byte r = System.Convert.ToByte(hexString.Substring(0, 2), 16); - byte g = System.Convert.ToByte(hexString.Substring(2, 2), 16); - byte b = System.Convert.ToByte(hexString.Substring(4, 2), 16); - return new SKColor(r, g, b); - } - catch - { - // Return a safe default color on conversion failure - return SKColors.Black; - } - } - } - - // Return a safe default color if the input is invalid or empty - return SKColors.Black; + byte r = System.Convert.ToByte(hexString.Substring(0, 2), 16); + byte g = System.Convert.ToByte(hexString.Substring(2, 2), 16); + byte b = System.Convert.ToByte(hexString.Substring(4, 2), 16); + return new SKColor(r, g, b); } + catch + { + // Return a safe default color on conversion failure + return SKColors.Black; + } + } } + + // Return a safe default color if the input is invalid or empty + return SKColors.Black; + } } diff --git a/Converters/IsToolActiveConverter.cs b/Converters/IsToolActiveConverter.cs index 28f9d22..9ecdf30 100644 --- a/Converters/IsToolActiveConverter.cs +++ b/Converters/IsToolActiveConverter.cs @@ -28,28 +28,28 @@ namespace LunaDraw.Converters; public class IsToolActiveConverter : IValueConverter { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is not IDrawingTool activeTool) - return false; - - if (parameter is string targetToolName) - { - // Handle special case for "Shapes" group - if (targetToolName == "Shapes") - { - return activeTool is RectangleTool or EllipseTool or LineTool; - } + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not IDrawingTool activeTool) + return false; - var activeTypeName = activeTool.GetType().Name; - return string.Equals(activeTypeName, targetToolName, StringComparison.Ordinal); - } + if (parameter is string targetToolName) + { + // Handle special case for "Shapes" group + if (targetToolName == "Shapes") + { + return activeTool is RectangleTool or EllipseTool or LineTool; + } - return false; + var activeTypeName = activeTool.GetType().Name; + return string.Equals(activeTypeName, targetToolName, StringComparison.Ordinal); } - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } } diff --git a/Converters/ToolNameToIconConverter.cs b/Converters/ToolNameToIconConverter.cs deleted file mode 100644 index dcbc904..0000000 --- a/Converters/ToolNameToIconConverter.cs +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025 CodeSoupCafe LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -using System.Globalization; - -namespace LunaDraw.Converters -{ - // Simple converter that maps tool names to an icon glyph (emoji for now). - // This keeps UI independent of a specific icon font and allows swapping to Syncfusion glyphs later. - public class ToolNameToIconConverter : IValueConverter - { - private static readonly Dictionary Map = new(StringComparer.OrdinalIgnoreCase) - { - { "Select", "🔲" }, - { "Line", "/" }, - { "Rectangle", "▭" }, - { "Ellipse", "◯" }, - { "Freehand", "✏️" }, - { "Eraser", "🧽" }, - { "Fill", "🖌️" }, - // Fallback for unknown tools - { "Default", "🔧" } - }; - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is string name && !string.IsNullOrWhiteSpace(name)) - { - if (Map.TryGetValue(name, out var glyph)) - return glyph; - - // Try to return the first character as a fallback - return name.Length > 0 ? name.Substring(0, 1) : Map["Default"]; - } - - return Map["Default"]; - } - - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } - } -} diff --git a/Converters/ToolToColorConverter.cs b/Converters/ToolToColorConverter.cs deleted file mode 100644 index 12da3bf..0000000 --- a/Converters/ToolToColorConverter.cs +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025 CodeSoupCafe LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -using System.Globalization; -using LunaDraw.Logic.Tools; - -namespace LunaDraw.Converters; - -public class ToolToColorConverter : IValueConverter -{ - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - bool isActive = false; - - if (value is IDrawingTool activeTool && parameter is string targetToolName) - { - if (targetToolName == "Shapes") - { - isActive = activeTool is RectangleTool or EllipseTool or LineTool; - } - else - { - isActive = string.Equals(activeTool.GetType().Name, targetToolName, StringComparison.Ordinal); - } - } - - if (isActive) - { - if (Application.Current?.Resources.TryGetValue("Secondary", out var color) == true) - return color; - return Colors.Orange; // Fallback - } - - // Inactive Color - var isDark = Application.Current?.RequestedTheme == AppTheme.Dark; - var key = isDark ? "Gray700" : "Gray200"; - - if (Application.Current?.Resources.TryGetValue(key, out var inactiveColor) == true) - return inactiveColor; - - return isDark ? Colors.DarkGray : Colors.LightGray; // Fallback - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} diff --git a/Documentation/MissingFeatures.md b/Documentation/MissingFeatures.md index 126a98c..9bbbf2e 100644 --- a/Documentation/MissingFeatures.md +++ b/Documentation/MissingFeatures.md @@ -20,14 +20,6 @@ This document tracks features that are specified in the requirements or document - No Gallery View or ViewModel. - No file storage logic for saving/loading drawings (serialization). -## 3. Import Photos (Doodling on Pictures) - -**Status:** ❌ Missing -**Requirement:** Ability to import photos to the canvas background. -**Current State:** - -- `Canvas` logic supports drawing paths but does not have an "Image Layer" or background image support implemented in `MainViewModel` or `Layer` model. - ## 4. Audio/Haptic Feedback **Status:** ❌ Missing @@ -37,15 +29,6 @@ This document tracks features that are specified in the requirements or document - No `AudioManager` or sound services implemented in the current solution. - References to sound exist only in `Legacy` code. -## 5. Magical Brush Presets - -**Status:** ⚠️ Partial / Unverified -**Requirement:** 24+ high-impact brush presets (Glow, Neon, Fireworks, etc.). -**Current State:** - -- **Engine:** `FreehandTool` and `DrawableStamps` support the _capabilities_ (Glow, Rainbow, Jitter, Scatter). -- **Presets:** The specific list of 24+ configured presets is not visible in `ToolbarViewModel` or `ToolStateManager`. The UI allows manual configuration, but the child-friendly "pick and draw" presets need to be verified or implemented. - ## 6. Production Deployment **Status:** ❌ Missing diff --git a/Documentation/Screenshots/Luna Draw Fish Stamps Resize Vector.png b/Documentation/Screenshots/Luna Draw Fish Stamps Resize Vector.png new file mode 100644 index 0000000..98c93e8 Binary files /dev/null and b/Documentation/Screenshots/Luna Draw Fish Stamps Resize Vector.png differ diff --git a/Documentation/Screenshots/LunaDraw Dark Theme Trace Mode.png b/Documentation/Screenshots/LunaDraw Dark Theme Trace Mode.png new file mode 100644 index 0000000..02a47ed Binary files /dev/null and b/Documentation/Screenshots/LunaDraw Dark Theme Trace Mode.png differ diff --git a/Documentation/Screenshots/LunaDraw Dark Theme.png b/Documentation/Screenshots/LunaDraw Dark Theme.png new file mode 100644 index 0000000..874c8f2 Binary files /dev/null and b/Documentation/Screenshots/LunaDraw Dark Theme.png differ diff --git a/Documentation/Screenshots/LunaDraw Light Theme Trace Mode.png b/Documentation/Screenshots/LunaDraw Light Theme Trace Mode.png new file mode 100644 index 0000000..6dcf135 Binary files /dev/null and b/Documentation/Screenshots/LunaDraw Light Theme Trace Mode.png differ diff --git a/Documentation/Screenshots/LunaDraw Light Theme.png b/Documentation/Screenshots/LunaDraw Light Theme.png new file mode 100644 index 0000000..efa71a6 Binary files /dev/null and b/Documentation/Screenshots/LunaDraw Light Theme.png differ diff --git a/Documentation/SkiaSharp.md b/Documentation/SkiaSharp SDK References.md similarity index 100% rename from Documentation/SkiaSharp.md rename to Documentation/SkiaSharp SDK References.md diff --git a/Logic/Extensions/PreferencesExtensions.cs b/Logic/Extensions/PreferencesExtensions.cs new file mode 100644 index 0000000..7d4a09b --- /dev/null +++ b/Logic/Extensions/PreferencesExtensions.cs @@ -0,0 +1,24 @@ +using LunaDraw.Logic.Utils; +using SkiaSharp; + +namespace LunaDraw.Logic.Extensions; + +public static class PreferencesExtensions +{ + public static SKColor GetCanvasBackgroundColor(this IPreferencesFacade preferencesFacade) + { + var isTransparentBackground = preferencesFacade.Get(AppPreference.IsTransparentBackgroundEnabled); + + if (isTransparentBackground) return SKColors.Transparent; + + var selectedTheme = Application.Current?.RequestedTheme; + var settingTheme = preferencesFacade.Get(AppPreference.AppTheme); + + if (settingTheme != PreferencesFacade.Defaults[AppPreference.AppTheme]) + { + selectedTheme = settingTheme == AppTheme.Dark.ToString() ? AppTheme.Dark : AppTheme.Light; + } + + return selectedTheme == AppTheme.Dark ? SKColors.Black : SKColors.White; + } +} diff --git a/Logic/Extensions/SkiaSharpExtensions.cs b/Logic/Extensions/SkiaSharpExtensions.cs index a0671dd..1c0c04e 100644 --- a/Logic/Extensions/SkiaSharpExtensions.cs +++ b/Logic/Extensions/SkiaSharpExtensions.cs @@ -23,184 +23,250 @@ using SkiaSharp; -namespace LunaDraw.Logic.Extensions +namespace LunaDraw.Logic.Extensions; + +public static class SkiaSharpExtensions { - public static class SkiaSharpExtensions + public static SKBitmap LoadBitmapDownsampled(string path, int targetWidth, int targetHeight) { - public static int GetAlphaPixelCount(this SKPixmap pixmap) + try { - return GetAlphaPixelCounts(pixmap)[0]; - } + if (!File.Exists(path)) + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] File not found: {path}"); - public static SKRect AspectFitFill(this SKRect bounds, int width, int height) => - width < height - ? bounds.AspectFit(new SKSize(width, height)) - : bounds.AspectFill(new SKSize(width, height)); + return new SKBitmap(); + } - public static SKPoint MapToInversePoint(this SKMatrix matrix, SKPoint point) - { - if (matrix.TryInvert(out var inverseMatrix)) + using var stream = File.OpenRead(path); + using var codec = SKCodec.Create(stream); + + if (codec == null) { - var transformedPoint = inverseMatrix.MapPoint(point); - return transformedPoint; + System.Diagnostics.Debug.WriteLine($"[BitmapCache] Failed to create codec for: {path}"); + + return new SKBitmap(); } - return point; - } + var info = codec.Info; - public static int[] GetAlphaPixelCounts(params SKPixmap[] pixmaps) - { - var totalAlphaPixels = (int[])Array.CreateInstance(typeof(int), pixmaps.Length); + // Calculate scale + float scale = 1.0f; + if (targetWidth > 0 && targetHeight > 0) + { + float scaleX = (float)targetWidth / info.Width; + float scaleY = (float)targetHeight / info.Height; + scale = Math.Min(scaleX, scaleY); + } - for (var xPos = 0; xPos < pixmaps[0].Width; xPos++) - for (var yPos = 0; yPos < pixmaps[0].Height; yPos++) - for (var pixmapCount = 0; pixmapCount < pixmaps.Length; pixmapCount++) - totalAlphaPixels[pixmapCount] += pixmaps[pixmapCount].GetPixelColor(xPos, yPos).Alpha > 0 ? 1 : 0; + if (scale >= 1.0f || (targetWidth == 0 && targetHeight == 0)) + { + return SKBitmap.Decode(codec); + } - return totalAlphaPixels; - } + // Get supported dimensions for this scale + var supportedInfo = codec.GetScaledDimensions(scale); - public static SKPaint AsOpacity(this SKPaint originalPaint, byte opacity = 50) - { - var opacityPaint = originalPaint.Clone(); - opacityPaint.Color = new SKColor(opacityPaint.Color.Red, opacityPaint.Color.Green, opacityPaint.Color.Blue, opacity); + // Use the supported dimensions for decoding + var decodeInfo = new SKImageInfo(supportedInfo.Width, supportedInfo.Height, info.ColorType, info.AlphaType); - return opacityPaint; - } + var bitmap = new SKBitmap(decodeInfo); + var result = codec.GetPixels(decodeInfo, bitmap.GetPixels()); - public static SKImage FlipHorizontal(this SKImage image) - { - if (image?.Encode()?.AsStream() is Stream imageStream) + if (result == SKCodecResult.Success || result == SKCodecResult.IncompleteInput) { - var headerSize = 8; - //var imageDataFlipped = ReadFully2(imageStream, image.Height, 4); - var buffer = new byte[imageStream.Length]; - imageStream.ReadExactly(buffer, 0, (int)imageStream.Length); - var imageDataFlipped = FlipBytesHorizontal(4, buffer.Skip(headerSize).ToArray()); - - return SKImage.FromEncodedData(buffer.Take(headerSize).Concat(imageDataFlipped).ToArray()); + return bitmap; + } + else + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] GetPixels failed: {result}"); + bitmap.Dispose(); + // Fallback: try full decode if downsample fails? + // Or maybe the scale was just invalid. + return new SKBitmap(); } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] Exception loading bitmap: {ex}"); - return default!; + return new SKBitmap(); } + } - private static byte[] FlipBytesVertical(int size, byte[] inputArray) - { - byte[] reversedArray = new byte[inputArray.Length]; + public static int GetAlphaPixelCount(this SKPixmap pixmap) + { + return GetAlphaPixelCounts(pixmap)[0]; + } - for (int i = 0; i < inputArray.Length / size; i++) - { - Array.Copy(inputArray, reversedArray.Length - (i + 1) * size, reversedArray, i * size, size); - } + public static SKRect AspectFitFill(this SKRect bounds, int width, int height) => + width < height + ? bounds.AspectFit(new SKSize(width, height)) + : bounds.AspectFill(new SKSize(width, height)); - return reversedArray; + public static SKPoint MapToInversePoint(this SKMatrix matrix, SKPoint point) + { + if (matrix.TryInvert(out var inverseMatrix)) + { + var transformedPoint = inverseMatrix.MapPoint(point); + return transformedPoint; } - private static byte[] FlipBytesHorizontal(int size, byte[] inputArray) - { - byte[] reversedArray = new byte[inputArray.Length]; + return point; + } - for (int i = 0; i < inputArray.Length / size; i++) - for (int j = 0; j < size; j++) - reversedArray[i * size + j] = inputArray[(i + 1) * size - j - 1]; + public static int[] GetAlphaPixelCounts(params SKPixmap[] pixmaps) + { + var totalAlphaPixels = (int[])Array.CreateInstance(typeof(int), pixmaps.Length); - return reversedArray; - } + for (var xPos = 0; xPos < pixmaps[0].Width; xPos++) + for (var yPos = 0; yPos < pixmaps[0].Height; yPos++) + for (var pixmapCount = 0; pixmapCount < pixmaps.Length; pixmapCount++) + totalAlphaPixels[pixmapCount] += pixmaps[pixmapCount].GetPixelColor(xPos, yPos).Alpha > 0 ? 1 : 0; - public static byte[] ReadFully2(Stream stream, int rowCount, int bytesPerRow) - { - byte[] imageInfo = new byte[rowCount * bytesPerRow]; + return totalAlphaPixels; + } - int i = (rowCount - 1) * bytesPerRow; // get the index of the last row in the image + public static SKPaint AsOpacity(this SKPaint originalPaint, byte opacity = 50) + { + var opacityPaint = originalPaint.Clone(); + opacityPaint.Color = new SKColor(opacityPaint.Color.Red, opacityPaint.Color.Green, opacityPaint.Color.Blue, opacity); - while (i >= 0) - { - stream.ReadExactly(imageInfo, i, bytesPerRow); - i -= bytesPerRow; - } + return opacityPaint; + } + + public static SKImage FlipHorizontal(this SKImage image) + { + if (image?.Encode()?.AsStream() is Stream imageStream) + { + var headerSize = 8; + //var imageDataFlipped = ReadFully2(imageStream, image.Height, 4); + var buffer = new byte[imageStream.Length]; + imageStream.ReadExactly(buffer, 0, (int)imageStream.Length); + var imageDataFlipped = FlipBytesHorizontal(4, buffer.Skip(headerSize).ToArray()); - return imageInfo; + return SKImage.FromEncodedData(buffer.Take(headerSize).Concat(imageDataFlipped).ToArray()); } - private static byte[] ReverseFrameInPlace2(int stride, byte[] framePixels) - { - var reversedFramePixels = new byte[framePixels.Length]; - var lines = framePixels.Length / stride; + return default!; + } - for (var line = 0; line < lines; line++) - { - Array.Copy(framePixels, framePixels.Length - ((line + 1) * stride), reversedFramePixels, line * stride, stride); - } + private static byte[] FlipBytesVertical(int size, byte[] inputArray) + { + byte[] reversedArray = new byte[inputArray.Length]; - return reversedFramePixels; + for (int i = 0; i < inputArray.Length / size; i++) + { + Array.Copy(inputArray, reversedArray.Length - (i + 1) * size, reversedArray, i * size, size); } - public static float GetRotationDegrees(this SKMatrix matrix) + return reversedArray; + } + + private static byte[] FlipBytesHorizontal(int size, byte[] inputArray) + { + byte[] reversedArray = new byte[inputArray.Length]; + + for (int i = 0; i < inputArray.Length / size; i++) + for (int j = 0; j < size; j++) + reversedArray[i * size + j] = inputArray[(i + 1) * size - j - 1]; + + return reversedArray; + } + + public static byte[] ReadFully2(Stream stream, int rowCount, int bytesPerRow) + { + byte[] imageInfo = new byte[rowCount * bytesPerRow]; + + int i = (rowCount - 1) * bytesPerRow; // get the index of the last row in the image + + while (i >= 0) { - // SkewY is sin(angle) * scale, ScaleX is cos(angle) * scale - float rotationRadians = (float)Math.Atan2(matrix.SkewY, matrix.ScaleX); - return rotationRadians * 180f / (float)Math.PI; + stream.ReadExactly(imageInfo, i, bytesPerRow); + i -= bytesPerRow; } - public static (SKMatrix Transform, SKRect Bounds) CalculateRotatedBounds( - this SKMatrix canvasMatrix, - SKPoint startPoint, - SKPoint currentPoint) + return imageInfo; + } + + private static byte[] ReverseFrameInPlace2(int stride, byte[] framePixels) + { + var reversedFramePixels = new byte[framePixels.Length]; + var lines = framePixels.Length / stride; + + for (var line = 0; line < lines; line++) { - // Calculate rotation from CanvasMatrix - float rotationDegrees = canvasMatrix.GetRotationDegrees(); + Array.Copy(framePixels, framePixels.Length - ((line + 1) * stride), reversedFramePixels, line * stride, stride); + } - // Create alignment matrices - var toAligned = SKMatrix.CreateRotationDegrees(rotationDegrees); - var toWorld = SKMatrix.CreateRotationDegrees(-rotationDegrees); + return reversedFramePixels; + } - var p1 = toAligned.MapPoint(startPoint); - var p2 = toAligned.MapPoint(currentPoint); + public static float GetRotationDegrees(this SKMatrix matrix) + { + // SkewY is sin(angle) * scale, ScaleX is cos(angle) * scale + float rotationRadians = (float)Math.Atan2(matrix.SkewY, matrix.ScaleX); + return rotationRadians * 180f / (float)Math.PI; + } - var left = Math.Min(p1.X, p2.X); - var top = Math.Min(p1.Y, p2.Y); - var right = Math.Max(p1.X, p2.X); - var bottom = Math.Max(p1.Y, p2.Y); + public static (SKMatrix Transform, SKRect Bounds) CalculateRotatedBounds( + this SKMatrix canvasMatrix, + SKPoint startPoint, + SKPoint currentPoint) + { + // Calculate rotation from CanvasMatrix + float rotationDegrees = canvasMatrix.GetRotationDegrees(); - var width = right - left; - var height = bottom - top; + // Create alignment matrices + var toAligned = SKMatrix.CreateRotationDegrees(rotationDegrees); + var toWorld = SKMatrix.CreateRotationDegrees(-rotationDegrees); - // The Top-Left corner in aligned space - var alignedTL = new SKPoint(left, top); + var p1 = toAligned.MapPoint(startPoint); + var p2 = toAligned.MapPoint(currentPoint); - // Transform aligned Top-Left back to World space - var worldTL = toWorld.MapPoint(alignedTL); + var left = Math.Min(p1.X, p2.X); + var top = Math.Min(p1.Y, p2.Y); + var right = Math.Max(p1.X, p2.X); + var bottom = Math.Max(p1.Y, p2.Y); - // Assemble transform: Translate to World TL, then Rotate by -Degrees (which matches toWorld) - var translation = SKMatrix.CreateTranslation(worldTL.X, worldTL.Y); + var width = right - left; + var height = bottom - top; - var transformMatrix = SKMatrix.Concat(translation, toWorld); - var bounds = new SKRect(0, 0, width, height); + // The Top-Left corner in aligned space + var alignedTL = new SKPoint(left, top); - return (transformMatrix, bounds); - } + // Transform aligned Top-Left back to World space + var worldTL = toWorld.MapPoint(alignedTL); - public static SKMatrix MaxScaleCentered(this SKCanvas canvas, - int width, - int height, - SKRect bounds, - float imageX = 0, - float imageY = 0, - float imageScale = 1) - { - canvas.Translate(width / 2f, height / 2f); + // Assemble transform: Translate to World TL, then Rotate by -Degrees (which matches toWorld) + var translation = SKMatrix.CreateTranslation(worldTL.X, worldTL.Y); - var ratio = bounds.Width < bounds.Height - ? height / bounds.Height - : width / bounds.Width; + var transformMatrix = SKMatrix.Concat(translation, toWorld); + var bounds = new SKRect(0, 0, width, height); - canvas.Scale(ratio); - canvas.Translate(-bounds.MidX + imageX, -bounds.MidY + imageY); + return (transformMatrix, bounds); + } - if (imageScale != 1) - canvas.Scale(imageScale); + public static SKMatrix MaxScaleCentered(this SKCanvas canvas, + int width, + int height, + SKRect bounds, + float imageX = 0, + float imageY = 0, + float imageScale = 1) + { + canvas.Translate(width / 2f, height / 2f); - return canvas.TotalMatrix; - } + var ratio = bounds.Width < bounds.Height + ? height / bounds.Height + : width / bounds.Width; + + canvas.Scale(ratio); + canvas.Translate(-bounds.MidX + imageX, -bounds.MidY + imageY); + + if (imageScale != 1) + canvas.Scale(imageScale); + + return canvas.TotalMatrix; } } diff --git a/Logic/Handlers/CanvasInputHandler.cs b/Logic/Handlers/CanvasInputHandler.cs index 5169cd5..610dc9f 100644 --- a/Logic/Handlers/CanvasInputHandler.cs +++ b/Logic/Handlers/CanvasInputHandler.cs @@ -21,7 +21,7 @@ * */ -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using LunaDraw.Logic.Tools; @@ -30,330 +30,357 @@ using SkiaSharp; using SkiaSharp.Views.Maui; -namespace LunaDraw.Logic.Services +namespace LunaDraw.Logic.Utils; + +public class CanvasInputHandler( + ToolbarViewModel toolbarViewModel, + ILayerFacade layerFacade, + SelectionObserver selectionObserver, + NavigationModel navigationModel, + IMessageBus messageBus) : ICanvasInputHandler { - public class CanvasInputHandler( - ToolbarViewModel toolbarViewModel, - ILayerFacade layerFacade, - SelectionObserver selectionObserver, - NavigationModel navigationModel, - IMessageBus messageBus) : ICanvasInputHandler + private readonly ToolbarViewModel toolbarViewModel = toolbarViewModel; + private readonly ILayerFacade layerFacade = layerFacade; + private readonly SelectionObserver selectionObserver = selectionObserver; + private readonly NavigationModel navigationModel = navigationModel; + private readonly IMessageBus messageBus = messageBus; + + private readonly Dictionary activeTouches = []; + private long[]? gestureFingerIds; + private bool isMultiTouch = false; + private bool manipulatingSelection = false; + + // Gesture state + private SKPoint startCentroid; + private float startDistance; + private float startAngle; + private SKMatrix startMatrix; + private Dictionary startElementMatrices = []; + + // Smoothing + private SKMatrix previousOutputMatrix = SKMatrix.CreateIdentity(); + private Dictionary previousElementMatrices = []; + private const float SmoothingFactor = 0.1f; // Lower = more smoothing (0.3 was original) + + public void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort) { - private readonly ToolbarViewModel toolbarViewModel = toolbarViewModel; - private readonly ILayerFacade layerFacade = layerFacade; - private readonly SelectionObserver selectionObserver = selectionObserver; - private readonly NavigationModel navigationModel = navigationModel; - private readonly IMessageBus messageBus = messageBus; - - private readonly Dictionary activeTouches = []; - private bool isMultiTouch = false; - private bool manipulatingSelection = false; - - // Gesture state - private SKPoint startCentroid; - private float startDistance; - private float startAngle; - private SKMatrix startMatrix; - private Dictionary startElementMatrices = []; - - // Smoothing - private SKMatrix previousOutputMatrix = SKMatrix.CreateIdentity(); - private Dictionary previousElementMatrices = []; - private const float SmoothingFactor = 0.1f; // Lower = more smoothing (0.3 was original) - - public void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort) - { - if (layerFacade.CurrentLayer == null) return; + if (layerFacade.CurrentLayer == null) return; - var location = e.Location; + var location = e.Location; - // Right click = select - if (e.MouseButton == SKMouseButton.Right) + // Right click = select + if (e.MouseButton == SKMouseButton.Right) + { + if (e.ActionType == SKTouchAction.Pressed) { - if (e.ActionType == SKTouchAction.Pressed) - { - var selectTool = toolbarViewModel.AvailableTools.FirstOrDefault(t => t.Type == ToolType.Select); - if (selectTool != null) toolbarViewModel.ActiveTool = selectTool; + var selectTool = toolbarViewModel.AvailableTools.FirstOrDefault(t => t.Type == ToolType.Select); + if (selectTool != null) toolbarViewModel.ActiveTool = selectTool; - if (navigationModel.ViewMatrix.TryInvert(out var inverse)) - { - PerformContextSelection(inverse.MapPoint(location)); - } + if (navigationModel.ViewMatrix.TryInvert(out var inverse)) + { + PerformContextSelection(inverse.MapPoint(location)); } - return; } + return; + } + + // Track touches + switch (e.ActionType) + { + case SKTouchAction.Pressed: + activeTouches[e.Id] = location; + break; + case SKTouchAction.Released: + case SKTouchAction.Cancelled: + activeTouches.Remove(e.Id); + break; + case SKTouchAction.Moved: + activeTouches[e.Id] = location; + break; + } - // Track touches - switch (e.ActionType) + // Multi-touch state management + if (activeTouches.Count >= 2) + { + if (isMultiTouch && gestureFingerIds != null) { - case SKTouchAction.Pressed: - activeTouches[e.Id] = location; - break; - case SKTouchAction.Released: - case SKTouchAction.Cancelled: - activeTouches.Remove(e.Id); - break; - case SKTouchAction.Moved: - activeTouches[e.Id] = location; - break; + if (!activeTouches.ContainsKey(gestureFingerIds[0]) || !activeTouches.ContainsKey(gestureFingerIds[1])) + { + isMultiTouch = false; + } } - // Multi-touch state management - if (activeTouches.Count >= 2) + if (!isMultiTouch) { - if (!isMultiTouch) + isMultiTouch = true; + gestureFingerIds = activeTouches.Keys.Take(2).ToArray(); + + // Cancel drawing + if (toolbarViewModel.ActiveTool is IDrawingTool tool) { - isMultiTouch = true; + tool.OnTouchCancelled(CreateToolContext()); + } - // Cancel drawing - if (toolbarViewModel.ActiveTool is IDrawingTool tool) - { - tool.OnTouchCancelled(CreateToolContext()); - } + // Snapshot state + var touches = new[] { activeTouches[gestureFingerIds[0]], activeTouches[gestureFingerIds[1]] }; + startCentroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); + startDistance = Distance(touches[0], touches[1]); + startAngle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); + startMatrix = navigationModel.ViewMatrix; + previousOutputMatrix = navigationModel.ViewMatrix; - // Snapshot state - var touches = activeTouches.OrderBy(kvp => kvp.Key).Take(2).Select(kvp => kvp.Value).ToArray(); - startCentroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); - startDistance = Distance(touches[0], touches[1]); - startAngle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); - startMatrix = navigationModel.ViewMatrix; - previousOutputMatrix = navigationModel.ViewMatrix; - - // Check if manipulating selection - manipulatingSelection = false; - if (layerFacade.CurrentLayer?.IsLocked == false && selectionObserver.Selected.Any()) + // Check if manipulating selection + manipulatingSelection = false; + if (toolbarViewModel.ActiveTool.Type == ToolType.Select && + layerFacade.CurrentLayer?.IsLocked == false && + selectionObserver.Selected.Any()) + { + if (navigationModel.ViewMatrix.TryInvert(out var inv)) { - if (navigationModel.ViewMatrix.TryInvert(out var inv)) + foreach (var touch in activeTouches.Values) { - foreach (var touch in activeTouches.Values) + var worldPt = inv.MapPoint(touch); + if (selectionObserver.Selected.Any(el => el.HitTest(worldPt))) { - var worldPt = inv.MapPoint(touch); - if (selectionObserver.Selected.Any(el => el.HitTest(worldPt))) - { - manipulatingSelection = true; - startElementMatrices = selectionObserver.Selected.ToDictionary(el => el, el => el.TransformMatrix); - break; - } + manipulatingSelection = true; + startElementMatrices = selectionObserver.Selected.ToDictionary(el => el, el => el.TransformMatrix); + break; } } } } + } - // Handle multi-touch ONCE after all touch updates - if (e.ActionType == SKTouchAction.Moved) - { - HandleMultiTouch(); - } + // Handle multi-touch ONCE after all touch updates + if (e.ActionType == SKTouchAction.Moved) + { + HandleMultiTouch(); } - else + } + else + { + isMultiTouch = false; + gestureFingerIds = null; + manipulatingSelection = false; + startElementMatrices.Clear(); + previousOutputMatrix = SKMatrix.CreateIdentity(); + + // Single touch + if (navigationModel.ViewMatrix.TryInvert(out var inverse)) { - isMultiTouch = false; - manipulatingSelection = false; - startElementMatrices.Clear(); - previousOutputMatrix = SKMatrix.CreateIdentity(); + var worldPoint = inverse.MapPoint(location); + var context = CreateToolContext(); - // Single touch - if (navigationModel.ViewMatrix.TryInvert(out var inverse)) + switch (e.ActionType) { - var worldPoint = inverse.MapPoint(location); - var context = CreateToolContext(); - - switch (e.ActionType) - { - case SKTouchAction.Pressed: - HandleTouchPressed(worldPoint, context); - break; - case SKTouchAction.Moved: - toolbarViewModel.ActiveTool.OnTouchMoved(worldPoint, context); - break; - case SKTouchAction.Released: - toolbarViewModel.ActiveTool.OnTouchReleased(worldPoint, context); - break; - } + case SKTouchAction.Pressed: + HandleTouchPressed(worldPoint, context); + break; + case SKTouchAction.Moved: + toolbarViewModel.ActiveTool.OnTouchMoved(worldPoint, context); + break; + case SKTouchAction.Released: + toolbarViewModel.ActiveTool.OnTouchReleased(worldPoint, context); + break; } } } + } + + private void HandleMultiTouch() + { + if (gestureFingerIds == null || gestureFingerIds.Length < 2) return; + if (!activeTouches.ContainsKey(gestureFingerIds[0]) || !activeTouches.ContainsKey(gestureFingerIds[1])) return; + + var touches = new[] { activeTouches[gestureFingerIds[0]], activeTouches[gestureFingerIds[1]] }; + + // Current centroid (average of both fingers) + var centroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); + + // Calculate current gesture state + float distance = Distance(touches[0], touches[1]); + float angle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); - private void HandleMultiTouch() + // Calculate transform from start + var translation = centroid - startCentroid; + float scale = startDistance > 0.001f ? distance / startDistance : 1.0f; + float rotation = angle - startAngle; + + // Aggressive deadzones to filter out noise + if (Math.Abs(scale - 1.0f) < 0.01f) scale = 1.0f; // Reduced from 0.05f + if (Math.Abs(rotation) < 0.05f) rotation = 0f; // Reduced from 0.2f (~3 degrees) + + // Build transform around start centroid + var transform = SKMatrix.CreateIdentity(); + transform = transform.PostConcat(SKMatrix.CreateTranslation(-startCentroid.X, -startCentroid.Y)); + transform = transform.PostConcat(SKMatrix.CreateScale(scale, scale)); + transform = transform.PostConcat(SKMatrix.CreateRotation(rotation)); + transform = transform.PostConcat(SKMatrix.CreateTranslation(startCentroid.X, startCentroid.Y)); + transform = transform.PostConcat(SKMatrix.CreateTranslation(translation.X, translation.Y)); + + if (manipulatingSelection) { - var touches = activeTouches.OrderBy(kvp => kvp.Key).Take(2).Select(kvp => kvp.Value).ToArray(); - if (touches.Length < 2) return; - - // Current centroid (average of both fingers) - var centroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); - - // Calculate current gesture state - float distance = Distance(touches[0], touches[1]); - float angle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); - - // Calculate transform from start - var translation = centroid - startCentroid; - float scale = startDistance > 0.001f ? distance / startDistance : 1.0f; - float rotation = angle - startAngle; - - // Aggressive deadzones to filter out noise - if (Math.Abs(scale - 1.0f) < 0.05f) scale = 1.0f; - if (Math.Abs(rotation) < 0.2f) rotation = 0f; // ~11 degrees - - // Build transform around start centroid - var transform = SKMatrix.CreateIdentity(); - transform = transform.PostConcat(SKMatrix.CreateTranslation(-startCentroid.X, -startCentroid.Y)); - transform = transform.PostConcat(SKMatrix.CreateScale(scale, scale)); - transform = transform.PostConcat(SKMatrix.CreateRotation(rotation)); - transform = transform.PostConcat(SKMatrix.CreateTranslation(startCentroid.X, startCentroid.Y)); - transform = transform.PostConcat(SKMatrix.CreateTranslation(translation.X, translation.Y)); - - if (manipulatingSelection) + if (navigationModel.ViewMatrix.TryInvert(out var invView)) { - if (navigationModel.ViewMatrix.TryInvert(out var invView)) - { - var worldTransform = SKMatrix.Concat(invView, SKMatrix.Concat(transform, navigationModel.ViewMatrix)); + var worldTransform = SKMatrix.Concat(invView, SKMatrix.Concat(transform, navigationModel.ViewMatrix)); - foreach (var element in selectionObserver.Selected) + foreach (var element in selectionObserver.Selected) + { + if (startElementMatrices.TryGetValue(element, out var startMat)) { - if (startElementMatrices.TryGetValue(element, out var startMat)) - { - var elementTarget = SKMatrix.Concat(worldTransform, startMat); + var elementTarget = SKMatrix.Concat(worldTransform, startMat); - // Smooth element transforms too - if (!previousElementMatrices.ContainsKey(element)) - { - previousElementMatrices[element] = element.TransformMatrix; - } - - var smoothedElementMatrix = LerpMatrix(previousElementMatrices[element], elementTarget, SmoothingFactor); - element.TransformMatrix = smoothedElementMatrix; - previousElementMatrices[element] = smoothedElementMatrix; + // Smooth element transforms too + if (!previousElementMatrices.ContainsKey(element)) + { + previousElementMatrices[element] = element.TransformMatrix; } + + var smoothedElementMatrix = LerpMatrix(previousElementMatrices[element], elementTarget, SmoothingFactor); + element.TransformMatrix = smoothedElementMatrix; + previousElementMatrices[element] = smoothedElementMatrix; } } } - else - { - // Calculate target matrix - var targetMatrix = SKMatrix.Concat(transform, startMatrix); - - // Smooth the output using exponential moving average - var smoothedMatrix = LerpMatrix(previousOutputMatrix, targetMatrix, SmoothingFactor); - previousOutputMatrix = smoothedMatrix; + } + else + { + // Calculate target matrix + var targetMatrix = SKMatrix.Concat(transform, startMatrix); - navigationModel.ViewMatrix = smoothedMatrix; + // Clamp scale + float currentScale = targetMatrix.ScaleX; // Assuming uniform scale + if (currentScale < 0.1f) + { + float correction = 0.1f / currentScale; + targetMatrix = SKMatrix.Concat(SKMatrix.CreateScale(correction, correction, centroid.X, centroid.Y), targetMatrix); + } + else if (currentScale > 20.0f) + { + float correction = 20.0f / currentScale; + targetMatrix = SKMatrix.Concat(SKMatrix.CreateScale(correction, correction, centroid.X, centroid.Y), targetMatrix); } - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + // Smooth the output using exponential moving average + var smoothedMatrix = LerpMatrix(previousOutputMatrix, targetMatrix, SmoothingFactor); + previousOutputMatrix = smoothedMatrix; - private SKMatrix LerpMatrix(SKMatrix a, SKMatrix b, float t) - { - return new SKMatrix - { - ScaleX = a.ScaleX + (b.ScaleX - a.ScaleX) * t, - ScaleY = a.ScaleY + (b.ScaleY - a.ScaleY) * t, - SkewX = a.SkewX + (b.SkewX - a.SkewX) * t, - SkewY = a.SkewY + (b.SkewY - a.SkewY) * t, - TransX = a.TransX + (b.TransX - a.TransX) * t, - TransY = a.TransY + (b.TransY - a.TransY) * t, - Persp0 = a.Persp0 + (b.Persp0 - a.Persp0) * t, - Persp1 = a.Persp1 + (b.Persp1 - a.Persp1) * t, - Persp2 = a.Persp2 + (b.Persp2 - a.Persp2) * t - }; + navigationModel.ViewMatrix = smoothedMatrix; } - private void PerformContextSelection(SKPoint worldPoint) + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private SKMatrix LerpMatrix(SKMatrix a, SKMatrix b, float t) + { + return new SKMatrix { - IDrawableElement? hit = null; - Layer? hitLayer = null; + ScaleX = a.ScaleX + (b.ScaleX - a.ScaleX) * t, + ScaleY = a.ScaleY + (b.ScaleY - a.ScaleY) * t, + SkewX = a.SkewX + (b.SkewX - a.SkewX) * t, + SkewY = a.SkewY + (b.SkewY - a.SkewY) * t, + TransX = a.TransX + (b.TransX - a.TransX) * t, + TransY = a.TransY + (b.TransY - a.TransY) * t, + Persp0 = a.Persp0 + (b.Persp0 - a.Persp0) * t, + Persp1 = a.Persp1 + (b.Persp1 - a.Persp1) * t, + Persp2 = a.Persp2 + (b.Persp2 - a.Persp2) * t + }; + } - foreach (var layer in layerFacade.Layers.Reverse()) - { - if (!layer.IsVisible || layer.IsLocked) continue; + private void PerformContextSelection(SKPoint worldPoint) + { + IDrawableElement? hit = null; + Layer? hitLayer = null; - hit = layer.Elements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(worldPoint)); + foreach (var layer in layerFacade.Layers.Reverse()) + { + if (!layer.IsVisible || layer.IsLocked) continue; - if (hit != null) - { - hitLayer = layer; - break; - } - } + hit = layer.Elements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(worldPoint)); if (hit != null) { - if (!selectionObserver.Contains(hit)) - { - selectionObserver.Clear(); - selectionObserver.Add(hit); - } - if (hitLayer != null) layerFacade.CurrentLayer = hitLayer; + hitLayer = layer; + break; } - else + } + + if (hit != null) + { + if (!selectionObserver.Contains(hit)) { selectionObserver.Clear(); + selectionObserver.Add(hit); } - - messageBus.SendMessage(new CanvasInvalidateMessage()); + if (hitLayer != null) layerFacade.CurrentLayer = hitLayer; + } + else + { + selectionObserver.Clear(); } - private void HandleTouchPressed(SKPoint worldPoint, ToolContext context) + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private void HandleTouchPressed(SKPoint worldPoint, ToolContext context) + { + if (layerFacade.CurrentLayer?.IsLocked == true) return; + + if (toolbarViewModel.ActiveTool.Type == ToolType.Select) { - if (layerFacade.CurrentLayer?.IsLocked == true) return; + toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); - if (toolbarViewModel.ActiveTool.Type == ToolType.Select) + if (selectionObserver.Selected.Count > 0) { - toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); - - if (selectionObserver.Selected.Count > 0) + var layer = layerFacade.Layers.FirstOrDefault(l => l.Elements.Contains(selectionObserver.Selected[0])); + if (layer != null && layer != layerFacade.CurrentLayer) { - var layer = layerFacade.Layers.FirstOrDefault(l => l.Elements.Contains(selectionObserver.Selected[0])); - if (layer != null && layer != layerFacade.CurrentLayer) - { - layerFacade.CurrentLayer = layer; - } + layerFacade.CurrentLayer = layer; } - return; - } - - if (selectionObserver.Selected.Any()) - { - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); } + return; + } - toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); + if (selectionObserver.Selected.Any()) + { + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } - private float Distance(SKPoint p1, SKPoint p2) => - (float)Math.Sqrt((p2.X - p1.X) * (p2.X - p1.X) + (p2.Y - p1.Y) * (p2.Y - p1.Y)); + toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); + } + + private float Distance(SKPoint p1, SKPoint p2) => + (float)Math.Sqrt((p2.X - p1.X) * (p2.X - p1.X) + (p2.Y - p1.Y) * (p2.Y - p1.Y)); - private ToolContext CreateToolContext() + private ToolContext CreateToolContext() + { + return new ToolContext { - return new ToolContext - { - CurrentLayer = layerFacade.CurrentLayer!, - StrokeColor = toolbarViewModel.StrokeColor, - FillColor = toolbarViewModel.FillColor, - StrokeWidth = toolbarViewModel.StrokeWidth, - Opacity = toolbarViewModel.Opacity, - Flow = toolbarViewModel.Flow, - Spacing = toolbarViewModel.Spacing, - BrushShape = toolbarViewModel.CurrentBrushShape, - AllElements = layerFacade.Layers.SelectMany(l => l.Elements), - Layers = layerFacade.Layers, - SelectionObserver = selectionObserver, - Scale = navigationModel.ViewMatrix.ScaleX, - IsGlowEnabled = toolbarViewModel.IsGlowEnabled, - GlowColor = toolbarViewModel.GlowColor, - GlowRadius = toolbarViewModel.GlowRadius, - IsRainbowEnabled = toolbarViewModel.IsRainbowEnabled, - ScatterRadius = toolbarViewModel.ScatterRadius, - SizeJitter = toolbarViewModel.SizeJitter, - AngleJitter = toolbarViewModel.AngleJitter, - HueJitter = toolbarViewModel.HueJitter, - CanvasMatrix = navigationModel.ViewMatrix - }; - } + CurrentLayer = layerFacade.CurrentLayer!, + StrokeColor = toolbarViewModel.StrokeColor, + FillColor = toolbarViewModel.FillColor, + StrokeWidth = toolbarViewModel.StrokeWidth, + Opacity = toolbarViewModel.Opacity, + Flow = toolbarViewModel.Flow, + Spacing = toolbarViewModel.Spacing, + BrushShape = toolbarViewModel.CurrentBrushShape, + AllElements = layerFacade.Layers.SelectMany(l => l.Elements), + Layers = layerFacade.Layers, + SelectionObserver = selectionObserver, + Scale = navigationModel.ViewMatrix.ScaleX, + IsGlowEnabled = toolbarViewModel.IsGlowEnabled, + GlowColor = toolbarViewModel.GlowColor, + GlowRadius = toolbarViewModel.GlowRadius, + IsRainbowEnabled = toolbarViewModel.IsRainbowEnabled, + ScatterRadius = toolbarViewModel.ScatterRadius, + SizeJitter = toolbarViewModel.SizeJitter, + AngleJitter = toolbarViewModel.AngleJitter, + HueJitter = toolbarViewModel.HueJitter, + CanvasMatrix = navigationModel.ViewMatrix + }; } } \ No newline at end of file diff --git a/Logic/Handlers/ICanvasInputHandler.cs b/Logic/Handlers/ICanvasInputHandler.cs index f6f7a89..38bb4ac 100644 --- a/Logic/Handlers/ICanvasInputHandler.cs +++ b/Logic/Handlers/ICanvasInputHandler.cs @@ -26,10 +26,9 @@ // For SKCanvasView -namespace LunaDraw.Logic.Services +namespace LunaDraw.Logic.Utils; + +public interface ICanvasInputHandler { - public interface ICanvasInputHandler - { - void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort); - } + void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort); } diff --git a/Logic/Messages/BrushSettingsChangedMessage.cs b/Logic/Messages/BrushSettingsChangedMessage.cs index f4f2eec..8d91ce9 100644 --- a/Logic/Messages/BrushSettingsChangedMessage.cs +++ b/Logic/Messages/BrushSettingsChangedMessage.cs @@ -23,42 +23,41 @@ using SkiaSharp; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when brush settings (color, transparency) change. +/// +public class BrushSettingsChangedMessage( + SKColor? strokeColor = null, + SKColor? fillColor = null, + byte? transparency = null, + byte? flow = null, + float? spacing = null, + float? strokeWidth = null, + bool? isGlowEnabled = null, + SKColor? glowColor = null, + float? glowRadius = null, + bool? isRainbowEnabled = null, + float? scatterRadius = null, + float? sizeJitter = null, + float? angleJitter = null, + float? hueJitter = null, + bool shouldClearFillColor = false) { - /// - /// Message sent when brush settings (color, transparency) change. - /// - public class BrushSettingsChangedMessage( - SKColor? strokeColor = null, - SKColor? fillColor = null, - byte? transparency = null, - byte? flow = null, - float? spacing = null, - float? strokeWidth = null, - bool? isGlowEnabled = null, - SKColor? glowColor = null, - float? glowRadius = null, - bool? isRainbowEnabled = null, - float? scatterRadius = null, - float? sizeJitter = null, - float? angleJitter = null, - float? hueJitter = null, - bool shouldClearFillColor = false) - { - public SKColor? StrokeColor { get; } = strokeColor; - public SKColor? FillColor { get; } = fillColor; - public byte? Transparency { get; } = transparency; - public byte? Flow { get; } = flow; - public float? Spacing { get; } = spacing; - public float? StrokeWidth { get; } = strokeWidth; - public bool? IsGlowEnabled { get; } = isGlowEnabled; - public SKColor? GlowColor { get; } = glowColor; - public float? GlowRadius { get; } = glowRadius; - public bool? IsRainbowEnabled { get; } = isRainbowEnabled; - public float? ScatterRadius { get; } = scatterRadius; - public float? SizeJitter { get; } = sizeJitter; - public float? AngleJitter { get; } = angleJitter; - public float? HueJitter { get; } = hueJitter; - public bool ShouldClearFillColor { get; } = shouldClearFillColor; - } + public SKColor? StrokeColor { get; } = strokeColor; + public SKColor? FillColor { get; } = fillColor; + public byte? Transparency { get; } = transparency; + public byte? Flow { get; } = flow; + public float? Spacing { get; } = spacing; + public float? StrokeWidth { get; } = strokeWidth; + public bool? IsGlowEnabled { get; } = isGlowEnabled; + public SKColor? GlowColor { get; } = glowColor; + public float? GlowRadius { get; } = glowRadius; + public bool? IsRainbowEnabled { get; } = isRainbowEnabled; + public float? ScatterRadius { get; } = scatterRadius; + public float? SizeJitter { get; } = sizeJitter; + public float? AngleJitter { get; } = angleJitter; + public float? HueJitter { get; } = hueJitter; + public bool ShouldClearFillColor { get; } = shouldClearFillColor; } diff --git a/Logic/Messages/BrushShapeChangedMessage.cs b/Logic/Messages/BrushShapeChangedMessage.cs index 69dbd85..b01d8dc 100644 --- a/Logic/Messages/BrushShapeChangedMessage.cs +++ b/Logic/Messages/BrushShapeChangedMessage.cs @@ -23,10 +23,9 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +public class BrushShapeChangedMessage(BrushShape shape) { - public class BrushShapeChangedMessage(BrushShape shape) - { - public BrushShape Shape { get; } = shape; - } + public BrushShape Shape { get; } = shape; } diff --git a/Logic/Messages/CanvasInvalidateMessage.cs b/Logic/Messages/CanvasInvalidateMessage.cs index 10e3a0b..6d3f109 100644 --- a/Logic/Messages/CanvasInvalidateMessage.cs +++ b/Logic/Messages/CanvasInvalidateMessage.cs @@ -21,13 +21,12 @@ * */ -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent to request the canvas to invalidate and redraw. +/// +public class CanvasInvalidateMessage { - /// - /// Message sent to request the canvas to invalidate and redraw. - /// - public class CanvasInvalidateMessage - { - // No properties needed, just a signal - } + // No properties needed, just a signal } diff --git a/Logic/Messages/DrawingStateChangedMessage.cs b/Logic/Messages/DrawingStateChangedMessage.cs index a7bfc02..ac24e21 100644 --- a/Logic/Messages/DrawingStateChangedMessage.cs +++ b/Logic/Messages/DrawingStateChangedMessage.cs @@ -21,12 +21,11 @@ * */ -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// A message to indicate that the drawable state of the canvas has changed and a history snapshot should be taken. +/// +public class DrawingStateChangedMessage { - /// - /// A message to indicate that the drawable state of the canvas has changed and a history snapshot should be taken. - /// - public class DrawingStateChangedMessage - { - } } diff --git a/Logic/Messages/ElementAddedMessage.cs b/Logic/Messages/ElementAddedMessage.cs index 0eb6611..4dfee30 100644 --- a/Logic/Messages/ElementAddedMessage.cs +++ b/Logic/Messages/ElementAddedMessage.cs @@ -23,14 +23,13 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when a new element is added to a layer. +/// +public class ElementAddedMessage(IDrawableElement element, Layer targetLayer) { - /// - /// Message sent when a new element is added to a layer. - /// - public class ElementAddedMessage(IDrawableElement element, Layer targetLayer) - { - public IDrawableElement Element { get; } = element; - public Layer TargetLayer { get; } = targetLayer; - } + public IDrawableElement Element { get; } = element; + public Layer TargetLayer { get; } = targetLayer; } diff --git a/Logic/Messages/ElementRemovedMessage.cs b/Logic/Messages/ElementRemovedMessage.cs index f48acaf..8a63329 100644 --- a/Logic/Messages/ElementRemovedMessage.cs +++ b/Logic/Messages/ElementRemovedMessage.cs @@ -23,14 +23,13 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when an element is removed from a layer. +/// +public class ElementRemovedMessage(IDrawableElement element, Layer sourceLayer) { - /// - /// Message sent when an element is removed from a layer. - /// - public class ElementRemovedMessage(IDrawableElement element, Layer sourceLayer) - { - public IDrawableElement Element { get; } = element; - public Layer SourceLayer { get; } = sourceLayer; - } + public IDrawableElement Element { get; } = element; + public Layer SourceLayer { get; } = sourceLayer; } diff --git a/Logic/Messages/LayerChangedMessage.cs b/Logic/Messages/LayerChangedMessage.cs index 093fd8d..f7dc5f9 100644 --- a/Logic/Messages/LayerChangedMessage.cs +++ b/Logic/Messages/LayerChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when a layer's properties (e.g., visibility, lock status) change. +/// +public class LayerChangedMessage(Layer changedLayer) { - /// - /// Message sent when a layer's properties (e.g., visibility, lock status) change. - /// - public class LayerChangedMessage(Layer changedLayer) - { - public Layer ChangedLayer { get; } = changedLayer; - } + public Layer ChangedLayer { get; } = changedLayer; } diff --git a/Logic/Messages/SelectionChangedMessage.cs b/Logic/Messages/SelectionChangedMessage.cs index fdefcfd..2a1a7af 100644 --- a/Logic/Messages/SelectionChangedMessage.cs +++ b/Logic/Messages/SelectionChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when the selection of elements changes. +/// +public class SelectionChangedMessage(IEnumerable selectedElements) { - /// - /// Message sent when the selection of elements changes. - /// - public class SelectionChangedMessage(IEnumerable selectedElements) - { - public IEnumerable SelectedElements { get; } = selectedElements; - } + public IEnumerable SelectedElements { get; } = selectedElements; } diff --git a/Logic/Messages/ShowAdvancedSettingsMessage.cs b/Logic/Messages/ShowAdvancedSettingsMessage.cs new file mode 100644 index 0000000..ee83ec6 --- /dev/null +++ b/Logic/Messages/ShowAdvancedSettingsMessage.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +namespace LunaDraw.Logic.Messages; + +public class ShowAdvancedSettingsMessage +{ +} diff --git a/Logic/Messages/ToolChangedMessage.cs b/Logic/Messages/ToolChangedMessage.cs index dc2f7a0..d00bffd 100644 --- a/Logic/Messages/ToolChangedMessage.cs +++ b/Logic/Messages/ToolChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Tools; -namespace LunaDraw.Logic.Messages +namespace LunaDraw.Logic.Messages; + +/// +/// Message sent when the active drawing tool changes. +/// +public class ToolChangedMessage(IDrawingTool newTool) { - /// - /// Message sent when the active drawing tool changes. - /// - public class ToolChangedMessage(IDrawingTool newTool) - { - public IDrawingTool NewTool { get; } = newTool; - } + public IDrawingTool NewTool { get; } = newTool; } diff --git a/Logic/Messages/ViewOptionsChangedMessage.cs b/Logic/Messages/ViewOptionsChangedMessage.cs new file mode 100644 index 0000000..26987d8 --- /dev/null +++ b/Logic/Messages/ViewOptionsChangedMessage.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +namespace LunaDraw.Logic.Messages; + +public class ViewOptionsChangedMessage +{ + public bool ShowButtonLabels { get; } + public bool ShowLayersPanel { get; } + + public ViewOptionsChangedMessage(bool showButtonLabels, bool showLayersPanel) + { + ShowButtonLabels = showButtonLabels; + ShowLayersPanel = showLayersPanel; + } +} diff --git a/Logic/Models/BrushShape.cs b/Logic/Models/BrushShape.cs index 8317a56..94655f0 100644 --- a/Logic/Models/BrushShape.cs +++ b/Logic/Models/BrushShape.cs @@ -23,170 +23,576 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +public enum BrushShapeType { - public enum BrushShapeType + Circle, + Square, + Star, + Heart, + Sparkle, + Cloud, + Moon, + Lightning, + Diamond, + Triangle, + Hexagon, + Unicorn, + Giraffe, + Bear, + Elephant, + Tiger, + Monkey, + Fireworks, + Flower, + Sun, + Snowflake, + Butterfly, + Fish, + Paw, + Leaf, + MusicNote, + Smile, + Custom +} + +public class BrushShape +{ + public string Name { get; set; } = "Circle"; + public BrushShapeType Type { get; set; } = BrushShapeType.Circle; + public SKPath Path { get; set; } = new SKPath(); + + public static BrushShape Circle() + { + var path = new SKPath(); + path.AddCircle(0, 0, 10); + return new BrushShape { Name = "Circle", Type = BrushShapeType.Circle, Path = path }; + } + + public static BrushShape Square() + { + var path = new SKPath(); + path.AddRect(new SKRect(-10, -10, 10, 10)); + return new BrushShape { Name = "Square", Type = BrushShapeType.Square, Path = path }; + } + + public static BrushShape Star() + { + var starPath = new SKPath(); + starPath.MoveTo(0, -10); + starPath.LineTo(2.5f, -3.5f); + starPath.LineTo(9.5f, -2.5f); + starPath.LineTo(4.5f, 2.5f); + starPath.LineTo(6f, 9.5f); + starPath.LineTo(0, 6.5f); + starPath.LineTo(-6f, 9.5f); + starPath.LineTo(-4.5f, 2.5f); + starPath.LineTo(-9.5f, -2.5f); + starPath.LineTo(-2.5f, -3.5f); + starPath.Close(); + + return new BrushShape { Name = "Star", Type = BrushShapeType.Star, Path = starPath }; + } + + public static BrushShape Heart() + { + var path = new SKPath(); + // Heart shape logic + path.MoveTo(0, 5); + path.CubicTo(0, 5, -10, -5, -5, -10); + path.CubicTo(-2.5f, -12.5f, 0, -7.5f, 0, -2.5f); + path.CubicTo(0, -7.5f, 2.5f, -12.5f, 5, -10); + path.CubicTo(10, -5, 0, 5, 0, 5); + path.Close(); + + // Center it roughly + path.Transform(SKMatrix.CreateTranslation(0, 2.5f)); + + return new BrushShape { Name = "Heart", Type = BrushShapeType.Heart, Path = path }; + } + + public static BrushShape Sparkle() + { + var path = new SKPath(); + // Four-pointed star / sparkle + path.MoveTo(0, -10); + path.QuadTo(1, -1, 10, 0); + path.QuadTo(1, 1, 0, 10); + path.QuadTo(-1, 1, -10, 0); + path.QuadTo(-1, -1, 0, -10); + path.Close(); + + return new BrushShape { Name = "Sparkle", Type = BrushShapeType.Sparkle, Path = path }; + } + + public static BrushShape Cloud() + { + var path = new SKPath(); + path.MoveTo(-8, 0); + path.LineTo(8, 0); + path.ArcTo(new SKRect(4, -8, 12, 0), 0, -180, false); + path.ArcTo(new SKRect(-4, -12, 4, -4), 0, -180, false); + path.ArcTo(new SKRect(-12, -8, -4, 0), 0, -180, false); + path.Close(); + path.Transform(SKMatrix.CreateTranslation(0, 4)); // Center + return new BrushShape { Name = "Cloud", Type = BrushShapeType.Cloud, Path = path }; + } + + public static BrushShape Moon() + { + var path = new SKPath(); + path.AddArc(new SKRect(-10, -10, 10, 10), 30, 300); + // Cut out the inner part + // This is hard with basic paths without path ops (which might be heavy) + // Let's try a simple crescent approximation with two curves + path.Reset(); + path.MoveTo(0, -10); + path.ArcTo(new SKRect(-10, -10, 10, 10), 270, 180, false); + path.ArcTo(new SKRect(-5, -10, 5, 10), 90, -180, false); + path.Close(); + + return new BrushShape { Name = "Moon", Type = BrushShapeType.Moon, Path = path }; + } + + public static BrushShape Lightning() + { + var path = new SKPath(); + path.MoveTo(2, -10); + path.LineTo(-5, 0); + path.LineTo(0, 0); + path.LineTo(-2, 10); + path.LineTo(5, 0); + path.LineTo(0, 0); + path.Close(); + return new BrushShape { Name = "Lightning", Type = BrushShapeType.Lightning, Path = path }; + } + + public static BrushShape Diamond() + { + var path = new SKPath(); + path.MoveTo(0, -10); + path.LineTo(7, 0); + path.LineTo(0, 10); + path.LineTo(-7, 0); + path.Close(); + return new BrushShape { Name = "Diamond", Type = BrushShapeType.Diamond, Path = path }; + } + + public static BrushShape Triangle() + { + var path = new SKPath(); + path.MoveTo(0, -10); + path.LineTo(9, 5); + path.LineTo(-9, 5); + path.Close(); + path.Transform(SKMatrix.CreateTranslation(0, 2)); + return new BrushShape { Name = "Triangle", Type = BrushShapeType.Triangle, Path = path }; + } + + public static BrushShape Hexagon() + { + var path = new SKPath(); + for (int i = 0; i < 6; i++) { - Circle, - Square, - Star, - Heart, - Sparkle, - Cloud, - Moon, - Lightning, - Diamond, - Triangle, - Hexagon, - Custom + float angle = i * 60 * (float)Math.PI / 180; + float x = 10 * (float)Math.Sin(angle); + float y = -10 * (float)Math.Cos(angle); + if (i == 0) path.MoveTo(x, y); + else path.LineTo(x, y); } + path.Close(); + return new BrushShape { Name = "Hexagon", Type = BrushShapeType.Hexagon, Path = path }; + } + + public static BrushShape Unicorn() + { + var path = new SKPath(); + // Neck base + path.MoveTo(-2, 10); + // Chest/Neck front + path.QuadTo(0, 5, 2, 0); + // Snout + path.LineTo(6, 2); + path.LineTo(5, -2); + // Forehead + path.LineTo(1, -5); + // Horn + path.LineTo(2, -12); + path.LineTo(0, -6); + // Ears/Top of head + path.LineTo(-1, -7); + path.LineTo(-2, -5); + // Mane/Neck back + path.QuadTo(-5, -2, -6, 10); + path.Close(); + + return new BrushShape { Name = "Unicorn", Type = BrushShapeType.Unicorn, Path = path }; + } + + public static BrushShape Giraffe() + { + var path = new SKPath(); + // Neck base + path.MoveTo(-1, 10); + // Neck front + path.LineTo(0, -5); + // Jaw + path.LineTo(4, -4); + // Snout + path.LineTo(4, -7); + // Forehead + path.LineTo(1, -9); + // Ossicones (Horns) + path.LineTo(1.5f, -12); + path.LineTo(0.5f, -12); + path.LineTo(0.5f, -9); + // Head back + path.LineTo(-1, -8); + // Neck back + path.LineTo(-3, 10); + path.Close(); + return new BrushShape { Name = "Giraffe", Type = BrushShapeType.Giraffe, Path = path }; + } + + public static BrushShape Bear() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; // Use EvenOdd for holes + path.AddCircle(0, 0, 7); // Face + path.AddCircle(-6, -6, 3); // Left Ear + path.AddCircle(6, -6, 3); // Right Ear + + // Eyes (Holes) + path.AddCircle(-2.5f, -2, 1f, SKPathDirection.CounterClockwise); + path.AddCircle(2.5f, -2, 1f, SKPathDirection.CounterClockwise); + + // Nose/Mouth area (Hole) + var snout = new SKPath(); + snout.AddOval(new SKRect(-3, 1, 3, 5)); + path.AddPath(snout); + + return new BrushShape { Name = "Bear", Type = BrushShapeType.Bear, Path = path }; + } + + public static BrushShape Elephant() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; + // Full body profile facing Right + path.MoveTo(-9, 5); // Back leg bottom + path.LineTo(-9, -2); // Rump + path.QuadTo(-8, -6, -4, -6); // Back + path.LineTo(0, -5); // Shoulder area + path.QuadTo(2, -7, 6, -5); // Head top + path.LineTo(7, -3); // Forehead + path.QuadTo(9, -1, 9, 3); // Trunk outer top + path.LineTo(8, 4); // Trunk tip + path.LineTo(7, 3); // Trunk inner tip + path.QuadTo(7, 0, 6, 1); // Trunk inner curve + path.LineTo(6, 5); // Front leg + path.LineTo(4, 5); // Front leg width + path.LineTo(4, 2); // Under belly start + path.QuadTo(0, 3, -5, 2); // Belly + path.LineTo(-5, 5); // Back leg inner + path.Close(); + + // Big Ear + var ear = new SKPath(); + ear.MoveTo(1, -4); + ear.QuadTo(4, -5, 5, -1); + ear.QuadTo(4, 2, 2, 1); + ear.Close(); + path.AddPath(ear); + + // Eye (Hole) + path.AddCircle(5, -3.5f, 0.6f, SKPathDirection.CounterClockwise); + + return new BrushShape { Name = "Elephant", Type = BrushShapeType.Elephant, Path = path }; + } + + public static BrushShape Tiger() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; + path.AddCircle(0, 0, 7); // Face + // Ears + path.MoveTo(-5, -5); + path.LineTo(-7, -9); + path.LineTo(-3, -7); + path.Close(); + + path.MoveTo(5, -5); + path.LineTo(7, -9); + path.LineTo(3, -7); + path.Close(); + + // Eyes (Holes) + path.AddCircle(-2.5f, -2, 1f, SKPathDirection.CounterClockwise); + path.AddCircle(2.5f, -2, 1f, SKPathDirection.CounterClockwise); + + // Nose (Hole) + var nose = new SKPath(); + nose.MoveTo(-1, 2); + nose.LineTo(1, 2); + nose.LineTo(0, 4); + nose.Close(); + path.AddPath(nose); + + // Stripes (Holes on cheeks/forehead) + var stripes = new SKPath(); + // Left cheek + stripes.MoveTo(-7, 0); + stripes.LineTo(-4, 1); + stripes.LineTo(-7, 2); + stripes.Close(); + // Right cheek + stripes.MoveTo(7, 0); + stripes.LineTo(4, 1); + stripes.LineTo(7, 2); + stripes.Close(); + // Forehead + stripes.MoveTo(0, -7); + stripes.LineTo(-1, -5); + stripes.LineTo(1, -5); + stripes.Close(); + + path.AddPath(stripes); + + return new BrushShape { Name = "Tiger", Type = BrushShapeType.Tiger, Path = path }; + } + + public static BrushShape Monkey() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; + path.AddCircle(0, 0, 6); // Face + path.AddCircle(-7, 0, 2.5f); // Left Ear + path.AddCircle(7, 0, 2.5f); // Right Ear + + // Hair tuft + path.MoveTo(0, -6); + path.LineTo(-1, -8); + path.LineTo(1, -8); + path.Close(); - public class BrushShape + // Eyes (Holes) + path.AddCircle(-2, -1, 1f, SKPathDirection.CounterClockwise); + path.AddCircle(2, -1, 1f, SKPathDirection.CounterClockwise); + + // Mouth (Hole) + var mouth = new SKPath(); + mouth.MoveTo(-2, 3); + mouth.QuadTo(0, 5, 2, 3); + mouth.Close(); // Thin smile + path.AddPath(mouth); + + return new BrushShape { Name = "Monkey", Type = BrushShapeType.Monkey, Path = path }; + } + + public static BrushShape Fireworks() + { + var path = new SKPath(); + // Balanced burst with gravity arc + path.MoveTo(0, -2); // Center slightly up + + for (int i = 0; i < 8; i++) + { + float angleDeg = i * 45; + // Skip bottom ones to look like they are falling from top + if (angleDeg > 135 && angleDeg < 225) continue; + + float angle = angleDeg * (float)Math.PI / 180; + + // Start point near center + float sx = 2 * (float)Math.Sin(angle); + float sy = -2 * (float)Math.Cos(angle); + + // Control point (outward) + float cx = 8 * (float)Math.Sin(angle); + float cy = -8 * (float)Math.Cos(angle); + + // End point (drooping down) + // Add gravity component to Y + float ex = 10 * (float)Math.Sin(angle); + float ey = -10 * (float)Math.Cos(angle) + 4; + + // Draw trail as a tapered shape + path.MoveTo(sx, sy); + path.QuadTo(cx, cy, ex, ey); // Curve out and down + + // Taper back + path.LineTo(ex + 0.5f, ey); // Small width at tip + path.QuadTo(cx + 0.5f, cy, sx + 1f, sy); // Return curve + path.Close(); + } + + // Add a few loose sparks + path.AddCircle(0, -5, 1.2f); + path.AddCircle(-4, -2, 1f); + path.AddCircle(4, -2, 1f); + + return new BrushShape { Name = "Fireworks", Type = BrushShapeType.Fireworks, Path = path }; + } + + public static BrushShape Flower() + { + var path = new SKPath(); + path.AddCircle(0, 0, 3); // Center + for (int i = 0; i < 5; i++) + { + float angle = i * 72 * (float)Math.PI / 180; + float cx = 6 * (float)Math.Sin(angle); + float cy = -6 * (float)Math.Cos(angle); + path.AddCircle(cx, cy, 3.5f); + } + return new BrushShape { Name = "Flower", Type = BrushShapeType.Flower, Path = path }; + } + + public static BrushShape Sun() + { + var path = new SKPath(); + path.AddCircle(0, 0, 6); // Core + + // Triangular rays + for (int i = 0; i < 8; i++) { - public string Name { get; set; } = "Circle"; - public BrushShapeType Type { get; set; } = BrushShapeType.Circle; - public SKPath Path { get; set; } = new SKPath(); + float angle = i * 45 * (float)Math.PI / 180; - public static BrushShape Circle() - { - var path = new SKPath(); - path.AddCircle(0, 0, 10); - return new BrushShape { Name = "Circle", Type = BrushShapeType.Circle, Path = path }; - } - - public static BrushShape Square() - { - var path = new SKPath(); - path.AddRect(new SKRect(-10, -10, 10, 10)); - return new BrushShape { Name = "Square", Type = BrushShapeType.Square, Path = path }; - } - - public static BrushShape Star() - { - var starPath = new SKPath(); - starPath.MoveTo(0, -10); - starPath.LineTo(2.5f, -3.5f); - starPath.LineTo(9.5f, -2.5f); - starPath.LineTo(4.5f, 2.5f); - starPath.LineTo(6f, 9.5f); - starPath.LineTo(0, 6.5f); - starPath.LineTo(-6f, 9.5f); - starPath.LineTo(-4.5f, 2.5f); - starPath.LineTo(-9.5f, -2.5f); - starPath.LineTo(-2.5f, -3.5f); - starPath.Close(); - - return new BrushShape { Name = "Star", Type = BrushShapeType.Star, Path = starPath }; - } - - public static BrushShape Heart() - { - var path = new SKPath(); - // Heart shape logic - path.MoveTo(0, 5); - path.CubicTo(0, 5, -10, -5, -5, -10); - path.CubicTo(-2.5f, -12.5f, 0, -7.5f, 0, -2.5f); - path.CubicTo(0, -7.5f, 2.5f, -12.5f, 5, -10); - path.CubicTo(10, -5, 0, 5, 0, 5); - path.Close(); - - // Center it roughly - path.Transform(SKMatrix.CreateTranslation(0, 2.5f)); - - return new BrushShape { Name = "Heart", Type = BrushShapeType.Heart, Path = path }; - } - - public static BrushShape Sparkle() - { - var path = new SKPath(); - // Four-pointed star / sparkle - path.MoveTo(0, -10); - path.QuadTo(1, -1, 10, 0); - path.QuadTo(1, 1, 0, 10); - path.QuadTo(-1, 1, -10, 0); - path.QuadTo(-1, -1, 0, -10); - path.Close(); - - return new BrushShape { Name = "Sparkle", Type = BrushShapeType.Sparkle, Path = path }; - } - - public static BrushShape Cloud() - { - var path = new SKPath(); - path.MoveTo(-8, 0); - path.LineTo(8, 0); - path.ArcTo(new SKRect(4, -8, 12, 0), 0, -180, false); - path.ArcTo(new SKRect(-4, -12, 4, -4), 0, -180, false); - path.ArcTo(new SKRect(-12, -8, -4, 0), 0, -180, false); - path.Close(); - path.Transform(SKMatrix.CreateTranslation(0, 4)); // Center - return new BrushShape { Name = "Cloud", Type = BrushShapeType.Cloud, Path = path }; - } - - public static BrushShape Moon() - { - var path = new SKPath(); - path.AddArc(new SKRect(-10, -10, 10, 10), 30, 300); - // Cut out the inner part - // This is hard with basic paths without path ops (which might be heavy) - // Let's try a simple crescent approximation with two curves - path.Reset(); - path.MoveTo(0, -10); - path.ArcTo(new SKRect(-10, -10, 10, 10), 270, 180, false); - path.ArcTo(new SKRect(-5, -10, 5, 10), 90, -180, false); - path.Close(); - - return new BrushShape { Name = "Moon", Type = BrushShapeType.Moon, Path = path }; - } - - public static BrushShape Lightning() - { - var path = new SKPath(); - path.MoveTo(2, -10); - path.LineTo(-5, 0); - path.LineTo(0, 0); - path.LineTo(-2, 10); - path.LineTo(5, 0); - path.LineTo(0, 0); - path.Close(); - return new BrushShape { Name = "Lightning", Type = BrushShapeType.Lightning, Path = path }; - } - - public static BrushShape Diamond() - { - var path = new SKPath(); - path.MoveTo(0, -10); - path.LineTo(7, 0); - path.LineTo(0, 10); - path.LineTo(-7, 0); - path.Close(); - return new BrushShape { Name = "Diamond", Type = BrushShapeType.Diamond, Path = path }; - } - - public static BrushShape Triangle() - { - var path = new SKPath(); - path.MoveTo(0, -10); - path.LineTo(9, 5); - path.LineTo(-9, 5); - path.Close(); - path.Transform(SKMatrix.CreateTranslation(0, 2)); - return new BrushShape { Name = "Triangle", Type = BrushShapeType.Triangle, Path = path }; - } - - public static BrushShape Hexagon() - { - var path = new SKPath(); - for (int i = 0; i < 6; i++) - { - float angle = i * 60 * (float)Math.PI / 180; - float x = 10 * (float)Math.Sin(angle); - float y = -10 * (float)Math.Cos(angle); - if (i == 0) path.MoveTo(x, y); - else path.LineTo(x, y); - } - path.Close(); - return new BrushShape { Name = "Hexagon", Type = BrushShapeType.Hexagon, Path = path }; - } + // Base of the triangle ray on the circle + float baseAngle1 = angle - (10 * (float)Math.PI / 180); + float baseAngle2 = angle + (10 * (float)Math.PI / 180); + + float x1 = 6 * (float)Math.Sin(baseAngle1); + float y1 = -6 * (float)Math.Cos(baseAngle1); + + float x2 = 6 * (float)Math.Sin(baseAngle2); + float y2 = -6 * (float)Math.Cos(baseAngle2); + + // Tip of the ray + float tipX = 11 * (float)Math.Sin(angle); + float tipY = -11 * (float)Math.Cos(angle); + + path.MoveTo(x1, y1); + path.LineTo(tipX, tipY); + path.LineTo(x2, y2); + path.Close(); + } + return new BrushShape { Name = "Sun", Type = BrushShapeType.Sun, Path = path }; + } + + public static BrushShape Snowflake() + { + var path = new SKPath(); + // Use rectangles for arms so they have width + for (int i = 0; i < 3; i++) // 3 bars crossing make 6 arms + { + path.AddRect(new SKRect(-1.5f, -10, 1.5f, 10)); // Vertical-ish bar + path.Transform(SKMatrix.CreateRotationDegrees(60)); + } + // Add some details on the ends (small diamonds) + var decorativePath = new SKPath(); + for(int i = 0; i < 6; i++) + { + decorativePath.AddCircle(0, -8, 2); + decorativePath.Transform(SKMatrix.CreateRotationDegrees(60)); } + path.AddPath(decorativePath); + + return new BrushShape { Name = "Snowflake", Type = BrushShapeType.Snowflake, Path = path }; + } + + public static BrushShape Butterfly() + { + var path = new SKPath(); + // Body + path.AddOval(new SKRect(-1, -6, 1, 6)); + // Wings + path.AddOval(new SKRect(-8, -8, -1, 0)); // Top Left + path.AddOval(new SKRect(1, -8, 8, 0)); // Top Right + path.AddOval(new SKRect(-6, 0, -1, 6)); // Bottom Left + path.AddOval(new SKRect(1, 0, 6, 6)); // Bottom Right + return new BrushShape { Name = "Butterfly", Type = BrushShapeType.Butterfly, Path = path }; + } + + public static BrushShape Fish() + { + var path = new SKPath(); + // Body (Flipped to face right) + path.AddOval(new SKRect(-4, -5, 8, 5)); + // Tail (Flipped to left side) + path.MoveTo(-4, 0); + path.LineTo(-8, -4); + path.LineTo(-8, 4); + path.Close(); + return new BrushShape { Name = "Fish", Type = BrushShapeType.Fish, Path = path }; + } + + public static BrushShape Paw() + { + var path = new SKPath(); + // Main pad + path.AddOval(new SKRect(-5, -2, 5, 6)); + // Toes + path.AddCircle(-4, -5, 2); + path.AddCircle(0, -6, 2); + path.AddCircle(4, -5, 2); + return new BrushShape { Name = "Paw", Type = BrushShapeType.Paw, Path = path }; + } + + public static BrushShape Leaf() + { + var path = new SKPath(); + // Wider body + path.MoveTo(0, -10); + path.CubicTo(8, -5, 8, 5, 0, 10); + path.CubicTo(-8, 5, -8, -5, 0, -10); + path.Close(); + return new BrushShape { Name = "Leaf", Type = BrushShapeType.Leaf, Path = path }; + } + + public static BrushShape MusicNote() + { + var path = new SKPath(); + path.AddOval(new SKRect(-4, 4, 2, 8)); // Head + + // Stem (Rectangle for thickness) + path.MoveTo(1, 6); + path.LineTo(2, 6); + path.LineTo(2, -6); + path.LineTo(1, -6); + path.Close(); + + // Flag (Polygon for thickness) + path.MoveTo(2, -6); + path.LineTo(6, -2); // Tip outer + path.LineTo(6, -0.5f); // Tip inner + path.QuadTo(4, -3, 2, -2); // Inner curve + path.Close(); + + return new BrushShape { Name = "MusicNote", Type = BrushShapeType.MusicNote, Path = path }; + } + + public static BrushShape Smile() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; // Ensure holes are subtracted + path.AddCircle(0, 0, 9); // Face + + // Eyes (as holes - simpler with EvenOdd, but let's reverse direction too just in case) + path.AddCircle(-3.5f, -3, 1.5f, SKPathDirection.CounterClockwise); + path.AddCircle(3.5f, -3, 1.5f, SKPathDirection.CounterClockwise); + + // Mouth (Crescent shape) + var mouth = new SKPath(); + mouth.MoveTo(-5, 2); + mouth.QuadTo(0, 7, 5, 2); // Bottom curve + mouth.QuadTo(0, 5, -5, 2); // Top curve + mouth.Close(); + + // Add mouth to main path (it should be treated as a hole if inside and EvenOdd or winding correct) + path.AddPath(mouth); + + return new BrushShape { Name = "Smile", Type = BrushShapeType.Smile, Path = path }; + } } diff --git a/Logic/Models/DrawableEllipse.cs b/Logic/Models/DrawableEllipse.cs index 3b3a352..5bf25e4 100644 --- a/Logic/Models/DrawableEllipse.cs +++ b/Logic/Models/DrawableEllipse.cs @@ -23,187 +23,186 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents an ellipse shape on the canvas. +/// +public class DrawableEllipse : IDrawableElement { - /// - /// Represents an ellipse shape on the canvas. - /// - public class DrawableEllipse : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKRect Oval { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Oval); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public SKRect Oval { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Oval); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawOval(Oval, highlightPaint); - } + if (!IsVisible) return; - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawOval(Oval, glowPaint); - } + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw fill if specified - if (FillColor.HasValue) - { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = FillColor.Value.WithAlpha(Opacity), - IsAntialias = true - }; - canvas.DrawOval(Oval, fillPaint); - } - - // Draw stroke - using var strokePaint = new SKPaint + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, IsAntialias = true }; - canvas.DrawOval(Oval, strokePaint); - - canvas.Restore(); + canvas.DrawOval(Oval, highlightPaint); } - public bool HitTest(SKPoint point) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - using var path = new SKPath(); - path.AddOval(Oval); - - // Check if filled and point is inside the fill path - if (FillColor.HasValue && path.Contains(localPoint.X, localPoint.Y)) - { - return true; - } - - // Check if point is near the stroke - using var paint = new SKPaint + using var glowPaint = new SKPaint { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 10 // Add tolerance + Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - - return strokedPath.Contains(localPoint.X, localPoint.Y); + canvas.DrawOval(Oval, glowPaint); } - public IDrawableElement Clone() + // Draw fill if specified + if (FillColor.HasValue) { - return new DrawableEllipse + using var fillPaint = new SKPaint { - Oval = Oval, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + Style = SKPaintStyle.Fill, + Color = FillColor.Value.WithAlpha(Opacity), + IsAntialias = true }; + canvas.DrawOval(Oval, fillPaint); } - public void Translate(SKPoint offset) + // Draw stroke + using var strokePaint = new SKPaint { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawOval(Oval, strokePaint); + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + using var path = new SKPath(); + path.AddOval(Oval); - public void Transform(SKMatrix matrix) + // Check if filled and point is inside the fill path + if (FillColor.HasValue && path.Contains(localPoint.X, localPoint.Y)) { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + return true; } - public SKPath GetPath() + // Check if point is near the stroke + using var paint = new SKPaint { - var path = new SKPath(); - path.AddOval(Oval); + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 10 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); - if (StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - - if (FillColor.HasValue) - { - var combined = new SKPath(); - path.Op(strokePath, SKPathOp.Union, combined); - path.Dispose(); - path = combined; - } - else - { - path.Dispose(); - path = strokePath; - } - } + return strokedPath.Contains(localPoint.X, localPoint.Y); + } - path.Transform(TransformMatrix); - return path; - } + public IDrawableElement Clone() + { + return new DrawableEllipse + { + Oval = Oval, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.AddOval(Oval); - public SKPath GetGeometryPath() + if (StrokeWidth > 0) { - var path = new SKPath(); - path.AddOval(Oval); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + + if (FillColor.HasValue) + { + var combined = new SKPath(); + path.Op(strokePath, SKPathOp.Union, combined); + path.Dispose(); + path = combined; + } + else + { + path.Dispose(); + path = strokePath; + } } + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.AddOval(Oval); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableGroup.cs b/Logic/Models/DrawableGroup.cs index 6c2b13f..88e5469 100644 --- a/Logic/Models/DrawableGroup.cs +++ b/Logic/Models/DrawableGroup.cs @@ -23,165 +23,164 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a group of drawable elements that can be manipulated as a single unit. +/// +public class DrawableGroup : IDrawableElement { - /// - /// Represents a group of drawable elements that can be manipulated as a single unit. - /// - public class DrawableGroup : IDrawableElement - { - public Guid Id { get; } = Guid.NewGuid(); - public List Children { get; } = []; - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + public Guid Id { get; } = Guid.NewGuid(); + public List Children { get; } = []; + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - public bool IsVisible { get; set; } = true; - private bool isSelected; - public bool IsSelected + public bool IsVisible { get; set; } = true; + private bool isSelected; + public bool IsSelected + { + get => isSelected; + set { - get => isSelected; - set + if (isSelected == value) return; + isSelected = value; + foreach (var child in Children) { - if (isSelected == value) return; - isSelected = value; - foreach (var child in Children) - { - child.IsSelected = value; - } + child.IsSelected = value; } } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } // Not directly used - public SKColor StrokeColor { get; set; } // Not directly used - public float StrokeWidth { get; set; } // Not directly used + } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } // Not directly used + public SKColor StrokeColor { get; set; } // Not directly used + public float StrokeWidth { get; set; } // Not directly used - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; - public SKRect Bounds + public SKRect Bounds + { + get { - get - { - if (!Children.Any()) return SKRect.Empty; + if (!Children.Any()) return SKRect.Empty; - var left = Children.Min(c => c.Bounds.Left); - var top = Children.Min(c => c.Bounds.Top); - var right = Children.Max(c => c.Bounds.Right); - var bottom = Children.Max(c => c.Bounds.Bottom); + var left = Children.Min(c => c.Bounds.Left); + var top = Children.Min(c => c.Bounds.Top); + var right = Children.Max(c => c.Bounds.Right); + var bottom = Children.Max(c => c.Bounds.Bottom); - return new SKRect(left, top, right, bottom); - } + return new SKRect(left, top, right, bottom); } + } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - // Check if isolation is needed (if any child uses Clear blend mode) - var needsIsolation = Children.OfType().Any(dp => dp.BlendMode == SKBlendMode.Clear); - - if (needsIsolation) - { - using var paint = new SKPaint { Color = SKColors.White.WithAlpha(Opacity) }; - canvas.SaveLayer(paint); - } + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; - // The group's transform is applied to children, not to the canvas here - foreach (var child in Children) - { - child.Draw(canvas); - } + // Check if isolation is needed (if any child uses Clear blend mode) + var needsIsolation = Children.OfType().Any(dp => dp.BlendMode == SKBlendMode.Clear); - if (needsIsolation) - { - canvas.Restore(); - } + if (needsIsolation) + { + using var paint = new SKPaint { Color = SKColors.White.WithAlpha(Opacity) }; + canvas.SaveLayer(paint); } - public bool HitTest(SKPoint point) + // The group's transform is applied to children, not to the canvas here + foreach (var child in Children) { - return Children.Any(child => child.HitTest(point)); + child.Draw(canvas); } - public IDrawableElement Clone() + if (needsIsolation) { - var newGroup = new DrawableGroup - { - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius - }; - foreach (var child in Children) - { - newGroup.Children.Add(child.Clone()); - } - return newGroup; + canvas.Restore(); } + } - public void Translate(SKPoint offset) + public bool HitTest(SKPoint point) + { + return Children.Any(child => child.HitTest(point)); + } + + public IDrawableElement Clone() + { + var newGroup = new DrawableGroup + { + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + foreach (var child in Children) { - var matrix = SKMatrix.CreateTranslation(offset.X, offset.Y); - Transform(matrix); + newGroup.Children.Add(child.Clone()); } + return newGroup; + } + + public void Translate(SKPoint offset) + { + var matrix = SKMatrix.CreateTranslation(offset.X, offset.Y); + Transform(matrix); + } - public void Transform(SKMatrix matrix) + public void Transform(SKMatrix matrix) + { + // Apply the transformation to all children + foreach (var child in Children) { - // Apply the transformation to all children - foreach (var child in Children) - { - child.Transform(matrix); - } + child.Transform(matrix); } + } - public SKPath GetPath() + public SKPath GetPath() + { + var path = new SKPath(); + foreach (var child in Children) { - var path = new SKPath(); - foreach (var child in Children) + using var childPath = child.GetPath(); + if (child is DrawablePath dp && dp.BlendMode == SKBlendMode.Clear) { - using var childPath = child.GetPath(); - if (child is DrawablePath dp && dp.BlendMode == SKBlendMode.Clear) + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Difference, result)) { - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Difference, result)) - { - path.Dispose(); - path = result; - } + path.Dispose(); + path = result; } - else + } + else + { + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Union, result)) { - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Union, result)) - { - path.Dispose(); - path = result; - } + path.Dispose(); + path = result; } } - return path; } + return path; + } - public SKPath GetGeometryPath() + public SKPath GetGeometryPath() + { + var path = new SKPath(); + foreach (var child in Children) { - var path = new SKPath(); - foreach (var child in Children) - { - using var childPath = child.GetGeometryPath(); - // Union all child geometry paths - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Union, result)) - { - path.Dispose(); - path = result; - } - } - return path; + using var childPath = child.GetGeometryPath(); + // Union all child geometry paths + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Union, result)) + { + path.Dispose(); + path = result; + } } + return path; } } \ No newline at end of file diff --git a/Logic/Models/DrawableImage.cs b/Logic/Models/DrawableImage.cs index 44c8c5e..309f2c8 100644 --- a/Logic/Models/DrawableImage.cs +++ b/Logic/Models/DrawableImage.cs @@ -23,167 +23,166 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +public class DrawableImage(SKBitmap bitmap) : IDrawableElement { - public class DrawableImage(SKBitmap bitmap) : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public string? SourcePath { get; set; } + public SKBitmap Bitmap { get; set; } = bitmap; + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + + // Images don't typically use FillColor, but we satisfy the interface. + // Could be used for tinting in the future. + public SKColor? FillColor { get; set; } + + // Stroke could be a border around the image + public SKColor StrokeColor { get; set; } = SKColors.Transparent; + public float StrokeWidth { get; set; } = 0; + + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); + + public void Draw(SKCanvas canvas) + { + if (!IsVisible || Bitmap == null) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); + + using var paint = new SKPaint + { + IsAntialias = true, + Color = SKColors.White.WithAlpha(Opacity) // Alpha affects the bitmap draw + }; + + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = 4 / matrix.ScaleX, // Adjust for scale + IsAntialias = true + }; + // Draw slightly outside + var highlightRect = bounds; + highlightRect.Inflate(2, 2); + canvas.DrawRect(highlightRect, highlightPaint); + } + + // Draw Glow + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowPaint = new SKPaint + { + Style = SKPaintStyle.StrokeAndFill, + Color = GlowColor.WithAlpha(Opacity), + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius), + IsAntialias = true + }; + // We draw the rect as the glow source + canvas.DrawRect(bounds, glowPaint); + } + + // Draw the Bitmap + using (var image = SKImage.FromBitmap(Bitmap)) + { + canvas.DrawImage(image, bounds, new SKSamplingOptions(SKCubicResampler.Mitchell), paint); + } + + // Draw Border if set + if (StrokeWidth > 0 && StrokeColor.Alpha > 0) + { + using var borderPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawRect(bounds, borderPaint); + } + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Bitmap == null) return false; + + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); + + return bounds.Contains(localPoint); + } + + public IDrawableElement Clone() + { + // Shallow copy of bitmap is usually sufficient unless we edit pixels. + // If we needed deep copy: Bitmap.Copy() + return new DrawableImage(Bitmap) + { + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, // Clones usually start unselected + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + // Return bounding path + var path = new SKPath(); + if (Bitmap != null) + { + path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); + } + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + if (Bitmap != null) { - public Guid Id { get; } = Guid.NewGuid(); - public string? SourcePath { get; set; } - public SKBitmap Bitmap { get; set; } = bitmap; - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - - // Images don't typically use FillColor, but we satisfy the interface. - // Could be used for tinting in the future. - public SKColor? FillColor { get; set; } - - // Stroke could be a border around the image - public SKColor StrokeColor { get; set; } = SKColors.Transparent; - public float StrokeWidth { get; set; } = 0; - - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible || Bitmap == null) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); - - using var paint = new SKPaint - { - IsAntialias = true, - Color = SKColors.White.WithAlpha(Opacity) // Alpha affects the bitmap draw - }; - - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = 4 / matrix.ScaleX, // Adjust for scale - IsAntialias = true - }; - // Draw slightly outside - var highlightRect = bounds; - highlightRect.Inflate(2, 2); - canvas.DrawRect(highlightRect, highlightPaint); - } - - // Draw Glow - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = SKPaintStyle.StrokeAndFill, - Color = GlowColor.WithAlpha(Opacity), - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius), - IsAntialias = true - }; - // We draw the rect as the glow source - canvas.DrawRect(bounds, glowPaint); - } - - // Draw the Bitmap - using (var image = SKImage.FromBitmap(Bitmap)) - { - canvas.DrawImage(image, bounds, new SKSamplingOptions(SKFilterMode.Linear), paint); - } - - // Draw Border if set - if (StrokeWidth > 0 && StrokeColor.Alpha > 0) - { - using var borderPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true - }; - canvas.DrawRect(bounds, borderPaint); - } - - canvas.Restore(); - } - - public bool HitTest(SKPoint point) - { - if (Bitmap == null) return false; - - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); - - return bounds.Contains(localPoint); - } - - public IDrawableElement Clone() - { - // Shallow copy of bitmap is usually sufficient unless we edit pixels. - // If we needed deep copy: Bitmap.Copy() - return new DrawableImage(Bitmap) - { - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, // Clones usually start unselected - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius - }; - } - - public void Translate(SKPoint offset) - { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } - - public void Transform(SKMatrix matrix) - { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } - - public SKPath GetPath() - { - // Return bounding path - var path = new SKPath(); - if (Bitmap != null) - { - path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - } - path.Transform(TransformMatrix); - return path; - } - - public SKPath GetGeometryPath() - { - var path = new SKPath(); - if (Bitmap != null) - { - path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - } - path.Transform(TransformMatrix); - return path; - } + path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); } + path.Transform(TransformMatrix); + return path; + } } diff --git a/Logic/Models/DrawableLine.cs b/Logic/Models/DrawableLine.cs index 6290c6b..0a369e9 100644 --- a/Logic/Models/DrawableLine.cs +++ b/Logic/Models/DrawableLine.cs @@ -23,174 +23,173 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a line shape on the canvas. +/// +public class DrawableLine : IDrawableElement { - /// - /// Represents a line shape on the canvas. - /// - public class DrawableLine : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKPoint StartPoint { get; set; } + public SKPoint EndPoint { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } // Not used for line + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds { - public Guid Id { get; } = Guid.NewGuid(); - public SKPoint StartPoint { get; set; } - public SKPoint EndPoint { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } // Not used for line - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds + get { - get - { - var localBounds = new SKRect( - Math.Min(StartPoint.X, EndPoint.X), - Math.Min(StartPoint.Y, EndPoint.Y), - Math.Max(StartPoint.X, EndPoint.X), - Math.Max(StartPoint.Y, EndPoint.Y) - ); - return TransformMatrix.MapRect(localBounds); - } + var localBounds = new SKRect( + Math.Min(StartPoint.X, EndPoint.X), + Math.Min(StartPoint.Y, EndPoint.Y), + Math.Max(StartPoint.X, EndPoint.X), + Math.Max(StartPoint.Y, EndPoint.Y) + ); + return TransformMatrix.MapRect(localBounds); } + } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawLine(StartPoint, EndPoint, highlightPaint); - } - - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawLine(StartPoint, EndPoint, glowPaint); - } - - using var paint = new SKPaint + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, IsAntialias = true }; - canvas.DrawLine(StartPoint, EndPoint, paint); - - canvas.Restore(); + canvas.DrawLine(StartPoint, EndPoint, highlightPaint); } - public bool HitTest(SKPoint point) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - // Use path-based hit testing for accuracy in local space - using var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); - - using var paint = new SKPaint + using var glowPaint = new SKPaint { Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 10 // Add tolerance - }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); - } - - public IDrawableElement Clone() - { - return new DrawableLine - { - StartPoint = StartPoint, - EndPoint = EndPoint, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - StrokeColor = StrokeColor, + Color = GlowColor.WithAlpha(Opacity), StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) }; + canvas.DrawLine(StartPoint, EndPoint, glowPaint); } - public void Translate(SKPoint offset) + using var paint = new SKPaint { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawLine(StartPoint, EndPoint, paint); + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; - public void Transform(SKMatrix matrix) + var localPoint = inverseMatrix.MapPoint(point); + + // Use path-based hit testing for accuracy in local space + using var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); + + using var paint = new SKPaint { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 10 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); + } - public SKPath GetPath() + public IDrawableElement Clone() + { + return new DrawableLine { - var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); + StartPoint = StartPoint, + EndPoint = EndPoint, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } - // Lines are always stroked (no fill) - if (StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeCap = SKStrokeCap.Round - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - path.Dispose(); - path = strokePath; - } - - path.Transform(TransformMatrix); - return path; - } + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); - public SKPath GetGeometryPath() + // Lines are always stroked (no fill) + if (StrokeWidth > 0) { - var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeCap = SKStrokeCap.Round + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + path.Dispose(); + path = strokePath; } + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawablePath.cs b/Logic/Models/DrawablePath.cs index ff13af1..056c037 100644 --- a/Logic/Models/DrawablePath.cs +++ b/Logic/Models/DrawablePath.cs @@ -23,215 +23,214 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a freehand drawn path on the canvas. +/// +public class DrawablePath : IDrawableElement { - /// - /// Represents a freehand drawn path on the canvas. - /// - public class DrawablePath : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public required SKPath Path { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public SKBlendMode BlendMode { get; set; } = SKBlendMode.SrcOver; + public bool IsFilled { get; set; } + public SKShader? FillShader { get; set; } + + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Path?.TightBounds ?? SKRect.Empty); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public required SKPath Path { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public SKBlendMode BlendMode { get; set; } = SKBlendMode.SrcOver; - public bool IsFilled { get; set; } - public SKShader? FillShader { get; set; } - - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Path?.TightBounds ?? SKRect.Empty); - - public void Draw(SKCanvas canvas) + if (!IsVisible || Path == null) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + if (IsGlowEnabled && GlowRadius > 0) { - if (!IsVisible || Path == null) return; + using var glowPaint = new SKPaint + { + Style = IsFilled ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) + }; + canvas.DrawPath(Path, glowPaint); + } - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, + IsAntialias = true + }; + canvas.DrawPath(Path, highlightPaint); + } - if (IsGlowEnabled && GlowRadius > 0) + // Draw Fill + if (IsFilled) + { + using var fillPaint = new SKPaint { - using var glowPaint = new SKPaint - { - Style = IsFilled ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawPath(Path, glowPaint); - } + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = BlendMode + }; - // Draw selection highlight - if (IsSelected) + if (FillShader != null) { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawPath(Path, highlightPaint); + fillPaint.Shader = FillShader; + // Modulate with opacity/color if needed, but usually white for image shaders + fillPaint.Color = SKColors.White.WithAlpha(Opacity); } - - // Draw Fill - if (IsFilled) + else if (FillColor.HasValue) { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = BlendMode - }; - - if (FillShader != null) - { - fillPaint.Shader = FillShader; - // Modulate with opacity/color if needed, but usually white for image shaders - fillPaint.Color = SKColors.White.WithAlpha(Opacity); - } - else if (FillColor.HasValue) - { - fillPaint.Color = FillColor.Value.WithAlpha(Opacity); - } - else - { - // Fallback: use StrokeColor as fill if no FillColor (matching legacy behavior) - fillPaint.Color = StrokeColor.WithAlpha(Opacity); - } - canvas.DrawPath(Path, fillPaint); + fillPaint.Color = FillColor.Value.WithAlpha(Opacity); } - - // Draw Stroke - if (StrokeWidth > 0) + else { - // Only draw stroke if it's an outline (not filled) OR if it has an explicit fill color (so we preserve border) - // If it is filled but has NO fill color, it is a "solid blob" using StrokeColor, so we skip stroking to avoid double-draw/expansion - // BUT if we have a FillShader, we definitely want the stroke if it exists. - bool shouldStroke = !IsFilled || (IsFilled && (FillColor.HasValue || FillShader != null)); - - if (shouldStroke) - { - using var strokePaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - BlendMode = BlendMode, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - canvas.DrawPath(Path, strokePaint); - } + // Fallback: use StrokeColor as fill if no FillColor (matching legacy behavior) + fillPaint.Color = StrokeColor.WithAlpha(Opacity); } - - canvas.Restore(); + canvas.DrawPath(Path, fillPaint); } - public bool HitTest(SKPoint point) + // Draw Stroke + if (StrokeWidth > 0) { - if (Path == null) return false; + // Only draw stroke if it's an outline (not filled) OR if it has an explicit fill color (so we preserve border) + // If it is filled but has NO fill color, it is a "solid blob" using StrokeColor, so we skip stroking to avoid double-draw/expansion + // BUT if we have a FillShader, we definitely want the stroke if it exists. + bool shouldStroke = !IsFilled || (IsFilled && (FillColor.HasValue || FillShader != null)); - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - // Check if point is within bounds first (faster) - if (!Path.TightBounds.Contains(localPoint)) return false; - - // Check if path contains point with tolerance - if (IsFilled) - { - return Path.Contains(localPoint.X, localPoint.Y); - } - else + if (shouldStroke) { - using var paint = new SKPaint + using var strokePaint = new SKPaint { Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 5 // Add tolerance + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true, + BlendMode = BlendMode, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round }; - using var strokedPath = new SKPath(); - paint.GetFillPath(Path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); + canvas.DrawPath(Path, strokePaint); } } - public IDrawableElement Clone() - { - return new DrawablePath - { - Path = new SKPath(Path), - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - BlendMode = BlendMode, - IsFilled = IsFilled, - FillShader = FillShader // Share shader reference - }; - } + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Path == null) return false; - public void Translate(SKPoint offset) + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + // Check if point is within bounds first (faster) + if (!Path.TightBounds.Contains(localPoint)) return false; + + // Check if path contains point with tolerance + if (IsFilled) { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + return Path.Contains(localPoint.X, localPoint.Y); } - - public void Transform(SKMatrix matrix) + else { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 5 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(Path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); } + } - public SKPath GetPath() + public IDrawableElement Clone() + { + return new DrawablePath { - var path = new SKPath(Path); + Path = new SKPath(Path), + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + BlendMode = BlendMode, + IsFilled = IsFilled, + FillShader = FillShader // Share shader reference + }; + } - if (!IsFilled && StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - path.Dispose(); - path = strokePath; - } - // If IsFilled is true, we assume the path itself is the shape. - // If it has a stroke AND fill, we should technically union them, - // but for freehand paths, usually it's either stroke or fill. - // If we support both later, we can add the union logic here. + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } - path.Transform(TransformMatrix); - return path; - } + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } - public SKPath GetGeometryPath() + public SKPath GetPath() + { + var path = new SKPath(Path); + + if (!IsFilled && StrokeWidth > 0) { - var path = new SKPath(Path); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + path.Dispose(); + path = strokePath; } + // If IsFilled is true, we assume the path itself is the shape. + // If it has a stroke AND fill, we should technically union them, + // but for freehand paths, usually it's either stroke or fill. + // If we support both later, we can add the union logic here. + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(Path); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableRectangle.cs b/Logic/Models/DrawableRectangle.cs index e7144bf..73556f6 100644 --- a/Logic/Models/DrawableRectangle.cs +++ b/Logic/Models/DrawableRectangle.cs @@ -23,193 +23,192 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a rectangle shape on the canvas. +/// +public class DrawableRectangle : IDrawableElement { - /// - /// Represents a rectangle shape on the canvas. - /// - public class DrawableRectangle : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKRect Rectangle { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Rectangle); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public SKRect Rectangle { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Rectangle); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; + if (!IsVisible) return; - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawRect(Rectangle, highlightPaint); - } - - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { - using var glowPaint = new SKPaint - { - Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawRect(Rectangle, glowPaint); - } + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, + IsAntialias = true + }; + canvas.DrawRect(Rectangle, highlightPaint); + } - // Draw fill if specified - if (FillColor.HasValue) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowPaint = new SKPaint { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = FillColor.Value.WithAlpha(Opacity), - IsAntialias = true - }; - canvas.DrawRect(Rectangle, fillPaint); - } + Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) + }; + canvas.DrawRect(Rectangle, glowPaint); + } - // Draw stroke - using var strokePaint = new SKPaint + // Draw fill if specified + if (FillColor.HasValue) + { + using var fillPaint = new SKPaint { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Style = SKPaintStyle.Fill, + Color = FillColor.Value.WithAlpha(Opacity), IsAntialias = true }; - canvas.DrawRect(Rectangle, strokePaint); - - canvas.Restore(); + canvas.DrawRect(Rectangle, fillPaint); } - public bool HitTest(SKPoint point) + // Draw stroke + using var strokePaint = new SKPaint { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawRect(Rectangle, strokePaint); + + canvas.Restore(); + } - var localPoint = inverseMatrix.MapPoint(point); + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; - using var path = new SKPath(); - path.AddRect(Rectangle); + var localPoint = inverseMatrix.MapPoint(point); - // Check if filled and point is inside the fill path, ONLY if fill is not fully transparent (Alpha > 0) - if (FillColor.HasValue && FillColor.Value.Alpha > 0 && path.Contains(localPoint.X, localPoint.Y)) - { - return true; - } + using var path = new SKPath(); + path.AddRect(Rectangle); - // If fill was transparent or not hit, check if point is near the visible stroke (Alpha > 0) - if (StrokeWidth > 0 && StrokeColor.Alpha > 0) // Only hit visible strokes - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 3 // Reduced tolerance for hit testing - }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); - } - return false; // No visible fill or stroke hit + // Check if filled and point is inside the fill path, ONLY if fill is not fully transparent (Alpha > 0) + if (FillColor.HasValue && FillColor.Value.Alpha > 0 && path.Contains(localPoint.X, localPoint.Y)) + { + return true; } - public IDrawableElement Clone() + // If fill was transparent or not hit, check if point is near the visible stroke (Alpha > 0) + if (StrokeWidth > 0 && StrokeColor.Alpha > 0) // Only hit visible strokes { - return new DrawableRectangle + using var paint = new SKPaint { - Rectangle = Rectangle, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 3 // Reduced tolerance for hit testing }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); } + return false; // No visible fill or stroke hit + } - public void Translate(SKPoint offset) + public IDrawableElement Clone() + { + return new DrawableRectangle { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Rectangle = Rectangle, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } - public void Transform(SKMatrix matrix) - { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.AddRect(Rectangle); - public SKPath GetPath() + if (StrokeWidth > 0) { - var path = new SKPath(); - path.AddRect(Rectangle); + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeJoin = SKStrokeJoin.Miter + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); - if (StrokeWidth > 0) + if (FillColor.HasValue) { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeJoin = SKStrokeJoin.Miter - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - - if (FillColor.HasValue) - { - var combined = new SKPath(); - // Union the fill (original path) and the stroke - // path OP strokePath -> combined - path.Op(strokePath, SKPathOp.Union, combined); - path.Dispose(); - path = combined; - } - else - { - path.Dispose(); - path = strokePath; - } + var combined = new SKPath(); + // Union the fill (original path) and the stroke + // path OP strokePath -> combined + path.Op(strokePath, SKPathOp.Union, combined); + path.Dispose(); + path = combined; + } + else + { + path.Dispose(); + path = strokePath; } - - path.Transform(TransformMatrix); - return path; } - public SKPath GetGeometryPath() - { - var path = new SKPath(); - path.AddRect(Rectangle); - path.Transform(TransformMatrix); - return path; - } + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.AddRect(Rectangle); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableStamps.cs b/Logic/Models/DrawableStamps.cs index 86054ea..845924a 100644 --- a/Logic/Models/DrawableStamps.cs +++ b/Logic/Models/DrawableStamps.cs @@ -23,673 +23,672 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a series of stamped shapes (custom brush strokes). +/// +public class DrawableStamps : IDrawableElement { - /// - /// Represents a series of stamped shapes (custom brush strokes). - /// - public class DrawableStamps : IDrawableElement - { - private SKBitmap? cachedBitmap; - private SKPoint cacheOffset; - private bool isCacheDirty = true; + private SKBitmap? cachedBitmap; + private SKPoint cacheOffset; + private bool isCacheDirty = true; - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = Guid.NewGuid(); - private List points = []; - public List Points + private List points = []; + public List Points + { + get => points; + set { - get => points; - set - { - points = value; - InvalidateCache(); - } + points = value; + InvalidateCache(); } + } - private BrushShape shape = BrushShape.Circle(); - public BrushShape Shape + private BrushShape shape = BrushShape.Circle(); + public BrushShape Shape + { + get => shape; + set { - get => shape; - set - { - shape = value; - InvalidateCache(); - } - } - - private float size = 10f; - public float Size - { - get => size; - set - { - if (Math.Abs(size - value) > 0.001f) - { - size = value; - InvalidateCache(); - } - } + shape = value; + InvalidateCache(); } + } - private byte flow = 255; - public byte Flow - { - get => flow; - set - { - if (flow != value) - { - flow = value; - InvalidateCache(); - } - } + private float size = 10f; + public float Size + { + get => size; + set + { + if (Math.Abs(size - value) > 0.001f) + { + size = value; + InvalidateCache(); + } } + } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - private bool isVisible = true; - public bool IsVisible + private byte flow = 255; + public byte Flow + { + get => flow; + set { - get => isVisible; - set => isVisible = value; + if (flow != value) + { + flow = value; + InvalidateCache(); + } } + } - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - - private byte opacity = 255; - public byte Opacity - { - get => opacity; - set - { - if (opacity != value) - { - opacity = value; - InvalidateCache(); - } - } - } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - public SKColor? FillColor { get; set; } + private bool isVisible = true; + public bool IsVisible + { + get => isVisible; + set => isVisible = value; + } - private SKColor strokeColor = SKColors.Black; - public SKColor StrokeColor + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + + private byte opacity = 255; + public byte Opacity + { + get => opacity; + set { - get => strokeColor; - set - { - if (strokeColor != value) - { - strokeColor = value; - InvalidateCache(); - } - } + if (opacity != value) + { + opacity = value; + InvalidateCache(); + } } + } - public float StrokeWidth { get; set; } // Not used directly, using Size instead + public SKColor? FillColor { get; set; } - private SKBlendMode blendMode = SKBlendMode.SrcOver; - public SKBlendMode BlendMode + private SKColor strokeColor = SKColors.Black; + public SKColor StrokeColor + { + get => strokeColor; + set { - get => blendMode; - set - { - if (blendMode != value) - { - blendMode = value; - InvalidateCache(); - } - } + if (strokeColor != value) + { + strokeColor = value; + InvalidateCache(); + } } + } - public bool IsFilled { get; set; } = true; + public float StrokeWidth { get; set; } // Not used directly, using Size instead - private bool isGlowEnabled = false; - public bool IsGlowEnabled + private SKBlendMode blendMode = SKBlendMode.SrcOver; + public SKBlendMode BlendMode + { + get => blendMode; + set { - get => isGlowEnabled; - set - { - if (isGlowEnabled != value) - { - isGlowEnabled = value; - InvalidateCache(); - } - } + if (blendMode != value) + { + blendMode = value; + InvalidateCache(); + } } + } - private SKColor glowColor = SKColors.Transparent; - public SKColor GlowColor - { - get => glowColor; - set - { - if (glowColor != value) - { - glowColor = value; - InvalidateCache(); - } - } - } + public bool IsFilled { get; set; } = true; - private float glowRadius = 0f; - public float GlowRadius - { - get => glowRadius; - set - { - if (Math.Abs(glowRadius - value) > 0.001f) - { - glowRadius = value; - InvalidateCache(); - } - } + private bool isGlowEnabled = false; + public bool IsGlowEnabled + { + get => isGlowEnabled; + set + { + if (isGlowEnabled != value) + { + isGlowEnabled = value; + InvalidateCache(); + } } + } - private bool isRainbowEnabled; - public bool IsRainbowEnabled - { - get => isRainbowEnabled; - set - { - if (isRainbowEnabled != value) - { - isRainbowEnabled = value; - InvalidateCache(); - } - } + private SKColor glowColor = SKColors.Transparent; + public SKColor GlowColor + { + get => glowColor; + set + { + if (glowColor != value) + { + glowColor = value; + InvalidateCache(); + } } + } - private List rotations = []; - public List Rotations + private float glowRadius = 0f; + public float GlowRadius + { + get => glowRadius; + set { - get => rotations; - set - { - rotations = value; - InvalidateCache(); - } + if (Math.Abs(glowRadius - value) > 0.001f) + { + glowRadius = value; + InvalidateCache(); + } } + } - private float sizeJitter; - public float SizeJitter - { - get => sizeJitter; - set - { - if (Math.Abs(sizeJitter - value) > 0.001f) - { - sizeJitter = value; - InvalidateCache(); - } - } + private bool isRainbowEnabled; + public bool IsRainbowEnabled + { + get => isRainbowEnabled; + set + { + if (isRainbowEnabled != value) + { + isRainbowEnabled = value; + InvalidateCache(); + } } + } - private float angleJitter; - public float AngleJitter - { - get => angleJitter; - set - { - if (Math.Abs(angleJitter - value) > 0.001f) - { - angleJitter = value; - InvalidateCache(); - } - } + private List rotations = []; + public List Rotations + { + get => rotations; + set + { + rotations = value; + InvalidateCache(); } + } - private float hueJitter; - public float HueJitter - { - get => hueJitter; - set - { - if (Math.Abs(hueJitter - value) > 0.001f) - { - hueJitter = value; - InvalidateCache(); - } - } + private float sizeJitter; + public float SizeJitter + { + get => sizeJitter; + set + { + if (Math.Abs(sizeJitter - value) > 0.001f) + { + sizeJitter = value; + InvalidateCache(); + } } + } - private void InvalidateCache() + private float angleJitter; + public float AngleJitter + { + get => angleJitter; + set { - isCacheDirty = true; - cachedBitmap?.Dispose(); - cachedBitmap = null; + if (Math.Abs(angleJitter - value) > 0.001f) + { + angleJitter = value; + InvalidateCache(); + } } + } - private SKRect GetLocalBounds() - { - if (Points == null || !Points.Any()) return SKRect.Empty; - - // Conservative bounds with jitter - float maxScale = 1.0f + SizeJitter; // Assuming SizeJitter is 0-1 relative addition - float halfSize = Size * maxScale; - - float minX = Points.Min(p => p.X); - float minY = Points.Min(p => p.Y); - float maxX = Points.Max(p => p.X); - float maxY = Points.Max(p => p.Y); - - float glowPadding = IsGlowEnabled ? GlowRadius * 3 : 0; - float padding = glowPadding + 5; // Extra safety margin - - return new SKRect( - minX - halfSize - padding, - minY - halfSize - padding, - maxX + halfSize + padding, - maxY + halfSize + padding); + private float hueJitter; + public float HueJitter + { + get => hueJitter; + set + { + if (Math.Abs(hueJitter - value) > 0.001f) + { + hueJitter = value; + InvalidateCache(); + } } + } - public SKRect Bounds => TransformMatrix.MapRect(GetLocalBounds()); + private void InvalidateCache() + { + isCacheDirty = true; + cachedBitmap?.Dispose(); + cachedBitmap = null; + } - private void UpdateCache() - { - if (!isCacheDirty && cachedBitmap != null) return; - if (Points == null || !Points.Any() || Shape?.Path == null) return; + private SKRect GetLocalBounds() + { + if (Points == null || !Points.Any()) return SKRect.Empty; + + // Conservative bounds with jitter + float maxScale = 1.0f + SizeJitter; // Assuming SizeJitter is 0-1 relative addition + float halfSize = Size * maxScale; + + float minX = Points.Min(p => p.X); + float minY = Points.Min(p => p.Y); + float maxX = Points.Max(p => p.X); + float maxY = Points.Max(p => p.Y); + + float glowPadding = IsGlowEnabled ? GlowRadius * 3 : 0; + float padding = glowPadding + 5; // Extra safety margin + + return new SKRect( + minX - halfSize - padding, + minY - halfSize - padding, + maxX + halfSize + padding, + maxY + halfSize + padding); + } + + public SKRect Bounds => TransformMatrix.MapRect(GetLocalBounds()); + + private void UpdateCache() + { + if (!isCacheDirty && cachedBitmap != null) return; + if (Points == null || !Points.Any() || Shape?.Path == null) return; + + cachedBitmap?.Dispose(); + cachedBitmap = null; + + var bounds = GetLocalBounds(); + var width = (int)Math.Ceiling(bounds.Width); + var height = (int)Math.Ceiling(bounds.Height); + + if (width <= 0 || height <= 0) return; + + cachedBitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(cachedBitmap); + canvas.Clear(SKColors.Transparent); + canvas.Translate(-bounds.Left, -bounds.Top); + + DrawContent(canvas); + + cacheOffset = new SKPoint(bounds.Left, bounds.Top); + isCacheDirty = false; + } + + private void DrawContent(SKCanvas canvas) + { + if (Points == null || !Points.Any() || Shape?.Path == null) return; - cachedBitmap?.Dispose(); - cachedBitmap = null; + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); - var bounds = GetLocalBounds(); - var width = (int)Math.Ceiling(bounds.Width); - var height = (int)Math.Ceiling(bounds.Height); + using var sharedPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = BlendMode + }; + + // Pre-calculate or deterministically generate variations + // Using a simple random generator seeded with a constant for stability if needed, + // but here we might just use index-based hashing for stateless drawing. - if (width <= 0 || height <= 0) return; + // Glow pass (Optimized with SaveLayer) + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowLayerPaint = new SKPaint + { + ImageFilter = SKImageFilter.CreateBlur(GlowRadius, GlowRadius), + IsAntialias = true + }; - cachedBitmap = new SKBitmap(width, height); - using var canvas = new SKCanvas(cachedBitmap); - canvas.Clear(SKColors.Transparent); - canvas.Translate(-bounds.Left, -bounds.Top); + canvas.SaveLayer(glowLayerPaint); - DrawContent(canvas); + int index = 0; + foreach (var point in Points) + { + DrawSingleStamp(canvas, scaledPath, point, index, true, sharedPaint); + index++; + } - cacheOffset = new SKPoint(bounds.Left, bounds.Top); - isCacheDirty = false; + canvas.Restore(); // Apply blur } - private void DrawContent(SKCanvas canvas) - { - if (Points == null || !Points.Any() || Shape?.Path == null) return; - - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); - - using var sharedPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = BlendMode - }; - - // Pre-calculate or deterministically generate variations - // Using a simple random generator seeded with a constant for stability if needed, - // but here we might just use index-based hashing for stateless drawing. - - // Glow pass (Optimized with SaveLayer) - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowLayerPaint = new SKPaint - { - ImageFilter = SKImageFilter.CreateBlur(GlowRadius, GlowRadius), - IsAntialias = true - }; - - canvas.SaveLayer(glowLayerPaint); - - int index = 0; - foreach (var point in Points) - { - DrawSingleStamp(canvas, scaledPath, point, index, true, sharedPaint); - index++; - } - - canvas.Restore(); // Apply blur - } - - // Main pass - int i = 0; - foreach (var point in Points) - { - DrawSingleStamp(canvas, scaledPath, point, i, false, sharedPaint); - i++; - } + // Main pass + int i = 0; + foreach (var point in Points) + { + DrawSingleStamp(canvas, scaledPath, point, i, false, sharedPaint); + i++; } + } - private int GetStableSeed(SKPoint p) + private int GetStableSeed(SKPoint p) + { + unchecked { - unchecked - { - int hash = 17; - hash = hash * 23 + p.X.GetHashCode(); - hash = hash * 23 + p.Y.GetHashCode(); - return hash; - } + int hash = 17; + hash = hash * 23 + p.X.GetHashCode(); + hash = hash * 23 + p.Y.GetHashCode(); + return hash; } + } - private void DrawSingleStamp(SKCanvas canvas, SKPath basePath, SKPoint point, int index, bool isGlowPass, SKPaint paint) - { - // Deterministic Random based on point location - var random = new Random(GetStableSeed(point)); - - // Size Jitter - float scaleFactor = 1.0f; - if (SizeJitter > 0) - { - float jitter = (float)random.NextDouble() * SizeJitter; // 0 to SizeJitter - scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (scaleFactor < 0.1f) scaleFactor = 0.1f; - } - - // Angle Jitter - float rotationDelta = 0f; - if (AngleJitter > 0) - { - rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; // +/- AngleJitter - } - - // Color Jitter / Rainbow - SKColor color = isGlowPass ? GlowColor : StrokeColor; - - if (IsRainbowEnabled) - { - // Rainbow cycles through Hue based on index - float hue = index * 10 % 360; // 10 degrees per stamp - color = SKColor.FromHsl(hue, 100, 50); - } - else if (HueJitter > 0 && !isGlowPass) - { - // Apply Hue Jitter - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; // +/- HueJitter (0-1 -> 0-360) - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - // Apply opacity and flow - paint.Color = color.WithAlpha((byte)(Flow * (Opacity / 255f))); - - canvas.Save(); - canvas.Translate(point.X, point.Y); - - // Apply Base Rotation (from path direction) + Jitter - float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; - float finalRotation = baseRotation + rotationDelta; - - if (Math.Abs(finalRotation) > 0.001f) - { - canvas.RotateDegrees(finalRotation); - } - - if (scaleFactor != 1.0f) - { - canvas.Scale(scaleFactor); - } - - canvas.DrawPath(basePath, paint); - canvas.Restore(); + private void DrawSingleStamp(SKCanvas canvas, SKPath basePath, SKPoint point, int index, bool isGlowPass, SKPaint paint) + { + // Deterministic Random based on point location + var random = new Random(GetStableSeed(point)); + + // Size Jitter + float scaleFactor = 1.0f; + if (SizeJitter > 0) + { + float jitter = (float)random.NextDouble() * SizeJitter; // 0 to SizeJitter + scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (scaleFactor < 0.1f) scaleFactor = 0.1f; } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - if (IsSelected) - { - // Draw selection highlight based on simple bounds (ignoring glow for the box) - float halfSize = Size; - float minX = Points.Min(p => p.X); - float minY = Points.Min(p => p.Y); - float maxX = Points.Max(p => p.X); - float maxY = Points.Max(p => p.Y); - var localBounds = new SKRect(minX - halfSize, minY - halfSize, maxX + halfSize, maxY + halfSize); - - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = 2, - IsAntialias = true - }; - canvas.DrawRect(localBounds, highlightPaint); - } - - // Always try to use cache for content - UpdateCache(); - if (cachedBitmap != null) - { - canvas.DrawBitmap(cachedBitmap, cacheOffset); - } - else - { - // Fallback - DrawContent(canvas); - } - - canvas.Restore(); + // Angle Jitter + float rotationDelta = 0f; + if (AngleJitter > 0) + { + rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; // +/- AngleJitter } - public bool HitTest(SKPoint point) + // Color Jitter / Rainbow + SKColor color = isGlowPass ? GlowColor : StrokeColor; + + if (IsRainbowEnabled) + { + // Rainbow cycles through Hue based on index + float hue = index * 10 % 360; // 10 degrees per stamp + color = SKColor.FromHsl(hue, 100, 50); + } + else if (HueJitter > 0 && !isGlowPass) { - if (Points == null || !Points.Any() || Shape?.Path == null) return false; + // Apply Hue Jitter + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; // +/- HueJitter (0-1 -> 0-360) + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; + // Apply opacity and flow + paint.Color = color.WithAlpha((byte)(Flow * (Opacity / 255f))); - var localPoint = inverseMatrix.MapPoint(point); + canvas.Save(); + canvas.Translate(point.X, point.Y); - // We need to iterate through each stamp and perform a hit test on its individual path - // replicating the jitter and transformations - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var initialScaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(initialScaleMatrix); // Apply base scale once + // Apply Base Rotation (from path direction) + Jitter + float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; + float finalRotation = baseRotation + rotationDelta; - for (int index = 0; index < Points.Count; index++) - { - var stampPoint = Points[index]; - // Deterministic Random based on point location - var random = new Random(GetStableSeed(stampPoint)); - - // Calculate current stamp's transformations - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - // Create the individual stamp's path with its transformations - using var stampPath = new SKPath(scaledPath); // Start with the pre-scaled shape path - - // Apply individual stamp's jittered scale and rotation - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - stampPath.Transform(stampTransform); - - // Translate to stamp's center point - stampPath.Transform(SKMatrix.CreateTranslation(stampPoint.X, stampPoint.Y)); - - // Check for visible fill hit (Alpha > 0) - SKColor effectiveFillColor = StrokeColor; // Stamps are usually filled with stroke color for simplicity - - // If stamp opacity * element opacity makes it transparent, it shouldn't hit. - // For stamps, Flow * Opacity is the effective alpha applied to color. - byte effectiveAlpha = (byte)(Flow * (Opacity / 255f)); - - if (effectiveAlpha > 0 && stampPath.Contains(localPoint.X, localPoint.Y)) - { - return true; - } - } - - return false; // No stamp hit + if (Math.Abs(finalRotation) > 0.001f) + { + canvas.RotateDegrees(finalRotation); + } + + if (scaleFactor != 1.0f) + { + canvas.Scale(scaleFactor); } - public IDrawableElement Clone() + canvas.DrawPath(basePath, paint); + canvas.Restore(); + } + + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + if (IsSelected) { - return new DrawableStamps + // Draw selection highlight based on simple bounds (ignoring glow for the box) + float halfSize = Size; + float minX = Points.Min(p => p.X); + float minY = Points.Min(p => p.Y); + float maxX = Points.Max(p => p.X); + float maxY = Points.Max(p => p.Y); + var localBounds = new SKRect(minX - halfSize, minY - halfSize, maxX + halfSize, maxY + halfSize); + + using var highlightPaint = new SKPaint { - Points = new List(Points), - Shape = Shape, // Reference copy is fine for shape - Size = Size, - Flow = Flow, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - BlendMode = BlendMode, - IsFilled = IsFilled, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius, - IsRainbowEnabled = IsRainbowEnabled, - Rotations = new List(Rotations), - SizeJitter = SizeJitter, - AngleJitter = AngleJitter, - HueJitter = HueJitter + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = 2, + IsAntialias = true }; + canvas.DrawRect(localBounds, highlightPaint); } - public void Translate(SKPoint offset) + // Always try to use cache for content + UpdateCache(); + if (cachedBitmap != null) { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + canvas.DrawBitmap(cachedBitmap, cacheOffset); } - - public void Transform(SKMatrix matrix) + else { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + // Fallback + DrawContent(canvas); } - public SKPath GetPath() + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Points == null || !Points.Any() || Shape?.Path == null) return false; + + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + // We need to iterate through each stamp and perform a hit test on its individual path + // replicating the jitter and transformations + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var initialScaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(initialScaleMatrix); // Apply base scale once + + for (int index = 0; index < Points.Count; index++) { - // Returning a combined path is expensive but necessary if we want to convert to standard path - var combinedPath = new SKPath(); - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); + var stampPoint = Points[index]; + // Deterministic Random based on point location + var random = new Random(GetStableSeed(stampPoint)); + + // Calculate current stamp's transformations + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + // Create the individual stamp's path with its transformations + using var stampPath = new SKPath(scaledPath); // Start with the pre-scaled shape path + + // Apply individual stamp's jittered scale and rotation + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + stampPath.Transform(stampTransform); - for (int i = 0; i < Points.Count; i++) + // Translate to stamp's center point + stampPath.Transform(SKMatrix.CreateTranslation(stampPoint.X, stampPoint.Y)); + + // Check for visible fill hit (Alpha > 0) + SKColor effectiveFillColor = StrokeColor; // Stamps are usually filled with stroke color for simplicity + + // If stamp opacity * element opacity makes it transparent, it shouldn't hit. + // For stamps, Flow * Opacity is the effective alpha applied to color. + byte effectiveAlpha = (byte)(Flow * (Opacity / 255f)); + + if (effectiveAlpha > 0 && stampPath.Contains(localPoint.X, localPoint.Y)) { - var point = Points[i]; - var random = new Random(GetStableSeed(point)); - - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - var p = new SKPath(scaledPath); - - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - p.Transform(stampTransform); - - p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); - combinedPath.AddPath(p); + return true; } - combinedPath.Transform(TransformMatrix); - return combinedPath; } - public SKPath GetGeometryPath() + return false; // No stamp hit + } + + public IDrawableElement Clone() + { + return new DrawableStamps { - return GetPath(); // For stamps, the "geometry path" is the combined visual path. + Points = new List(Points), + Shape = Shape, // Reference copy is fine for shape + Size = Size, + Flow = Flow, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + BlendMode = BlendMode, + IsFilled = IsFilled, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius, + IsRainbowEnabled = IsRainbowEnabled, + Rotations = new List(Rotations), + SizeJitter = SizeJitter, + AngleJitter = AngleJitter, + HueJitter = HueJitter + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + // Returning a combined path is expensive but necessary if we want to convert to standard path + var combinedPath = new SKPath(); + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); + + for (int i = 0; i < Points.Count; i++) + { + var point = Points[i]; + var random = new Random(GetStableSeed(point)); + + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + var p = new SKPath(scaledPath); + + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + p.Transform(stampTransform); + + p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); + combinedPath.AddPath(p); } + combinedPath.Transform(TransformMatrix); + return combinedPath; + } + + public SKPath GetGeometryPath() + { + return GetPath(); // For stamps, the "geometry path" is the combined visual path. + } + + public IEnumerable<(SKPath Path, SKColor Color)> GetDetailedPaths() + { + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); + + for (int i = 0; i < Points.Count; i++) + { + var point = Points[i]; + var random = new Random(GetStableSeed(point)); + + // Path Calculation + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + var p = new SKPath(scaledPath); + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + p.Transform(stampTransform); + p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); + + // Apply Global Transform + p.Transform(TransformMatrix); + + // Color Calculation + SKColor color = StrokeColor; + if (IsRainbowEnabled) + { + float hue = i * 10 % 360; + color = SKColor.FromHsl(hue, 100, 50); + } + else if (HueJitter > 0) + { + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - public IEnumerable<(SKPath Path, SKColor Color)> GetDetailedPaths() - { - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); - - for (int i = 0; i < Points.Count; i++) - { - var point = Points[i]; - var random = new Random(GetStableSeed(point)); - - // Path Calculation - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - var p = new SKPath(scaledPath); - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - p.Transform(stampTransform); - p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); - - // Apply Global Transform - p.Transform(TransformMatrix); - - // Color Calculation - SKColor color = StrokeColor; - if (IsRainbowEnabled) - { - float hue = i * 10 % 360; - color = SKColor.FromHsl(hue, 100, 50); - } - else if (HueJitter > 0) - { - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - yield return (p, color); - } + yield return (p, color); } } } diff --git a/Logic/Models/IDrawableElement.cs b/Logic/Models/IDrawableElement.cs index af759ca..efd396f 100644 --- a/Logic/Models/IDrawableElement.cs +++ b/Logic/Models/IDrawableElement.cs @@ -23,109 +23,108 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Base interface for all drawable elements on the canvas. +/// Supports selection, visibility, layering, and manipulation. +/// +public interface IDrawableElement { - /// - /// Base interface for all drawable elements on the canvas. - /// Supports selection, visibility, layering, and manipulation. - /// - public interface IDrawableElement - { - /// - /// Unique identifier for this element. - /// - Guid Id { get; } - - /// - /// Bounding rectangle of the element in world coordinates. - /// - SKRect Bounds { get; } - - /// - /// The transformation matrix applied to the element. - /// - SKMatrix TransformMatrix { get; set; } - - /// - /// Whether the element is visible on the canvas. - /// - bool IsVisible { get; set; } - - /// - /// Whether the element is currently selected. - /// - bool IsSelected { get; set; } - - /// - /// Z-index for layering (higher values drawn on top). - /// - int ZIndex { get; set; } - - /// - /// Opacity of the element (0-255). - /// - byte Opacity { get; set; } - - /// - /// Fill color for the element (null for no fill). - /// - SKColor? FillColor { get; set; } - - /// - /// Stroke/border color for the element. - /// - SKColor StrokeColor { get; set; } - - /// - /// Width of the stroke/border. - /// - float StrokeWidth { get; set; } - bool IsGlowEnabled { get; set; } - SKColor GlowColor { get; set; } - float GlowRadius { get; set; } - - /// - /// Draws the element on the provided canvas. - /// - /// The SKCanvas to draw on. - void Draw(SKCanvas canvas); - - /// - /// Tests if a point hits this element. - /// - /// The point to test in world coordinates. - /// True if the point intersects with the element. - bool HitTest(SKPoint point); - - /// - /// Creates a deep copy of this element. - /// - /// A cloned instance of the element. - IDrawableElement Clone(); - - /// - /// Translates the element by the specified offset. - /// - /// The offset to move by. - void Translate(SKPoint offset); - - /// - /// Transforms the element using the provided matrix. - /// - /// The transformation matrix. - void Transform(SKMatrix matrix); - - /// - /// Gets the geometric path of the element in world coordinates. - /// - /// The SKPath representing the element. - SKPath GetPath(); - - /// - /// Gets the underlying geometry path without stroke expansion. - /// Used for operations that need the base shape. - /// - /// The SKPath representing the base geometry. - SKPath GetGeometryPath(); - } -} + /// + /// Unique identifier for this element. + /// + Guid Id { get; } + + /// + /// Bounding rectangle of the element in world coordinates. + /// + SKRect Bounds { get; } + + /// + /// The transformation matrix applied to the element. + /// + SKMatrix TransformMatrix { get; set; } + + /// + /// Whether the element is visible on the canvas. + /// + bool IsVisible { get; set; } + + /// + /// Whether the element is currently selected. + /// + bool IsSelected { get; set; } + + /// + /// Z-index for layering (higher values drawn on top). + /// + int ZIndex { get; set; } + + /// + /// Opacity of the element (0-255). + /// + byte Opacity { get; set; } + + /// + /// Fill color for the element (null for no fill). + /// + SKColor? FillColor { get; set; } + + /// + /// Stroke/border color for the element. + /// + SKColor StrokeColor { get; set; } + + /// + /// Width of the stroke/border. + /// + float StrokeWidth { get; set; } + bool IsGlowEnabled { get; set; } + SKColor GlowColor { get; set; } + float GlowRadius { get; set; } + + /// + /// Draws the element on the provided canvas. + /// + /// The SKCanvas to draw on. + void Draw(SKCanvas canvas); + + /// + /// Tests if a point hits this element. + /// + /// The point to test in world coordinates. + /// True if the point intersects with the element. + bool HitTest(SKPoint point); + + /// + /// Creates a deep copy of this element. + /// + /// A cloned instance of the element. + IDrawableElement Clone(); + + /// + /// Translates the element by the specified offset. + /// + /// The offset to move by. + void Translate(SKPoint offset); + + /// + /// Transforms the element using the provided matrix. + /// + /// The transformation matrix. + void Transform(SKMatrix matrix); + + /// + /// Gets the geometric path of the element in world coordinates. + /// + /// The SKPath representing the element. + SKPath GetPath(); + + /// + /// Gets the underlying geometry path without stroke expansion. + /// Used for operations that need the base shape. + /// + /// The SKPath representing the base geometry. + SKPath GetGeometryPath(); +} \ No newline at end of file diff --git a/Logic/Models/Layer.cs b/Logic/Models/Layer.cs index 9cd050f..026c206 100644 --- a/Logic/Models/Layer.cs +++ b/Logic/Models/Layer.cs @@ -27,142 +27,141 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a layer in the drawing, containing a collection of drawable elements. +/// Uses QuadTree for spatial indexing and simple culling for performance. +/// NO BITMAP TILING. +/// +public class Layer : ReactiveObject { - /// - /// Represents a layer in the drawing, containing a collection of drawable elements. - /// Uses QuadTree for spatial indexing and simple culling for performance. - /// NO BITMAP TILING. - /// - public class Layer : ReactiveObject - { - private string name = "Layer"; - private bool isVisible = true; - private bool isLocked = false; - private MaskingMode maskingMode = MaskingMode.None; + private string name = "Layer"; + private bool isVisible = true; + private bool isLocked = false; + private MaskingMode maskingMode = MaskingMode.None; - private QuadTree quadTree; + private QuadTreeMemento quadTree; - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = Guid.NewGuid(); - public Layer() - { - // Initialize QuadTree with large bounds (arbitrary large world) - var worldBounds = new SKRect(-500000, -500000, 500000, 500000); - quadTree = new QuadTree(0, worldBounds, e => e.Bounds); + public Layer() + { + // Initialize QuadTree with large bounds (arbitrary large world) + var worldBounds = new SKRect(-500000, -500000, 500000, 500000); + quadTree = new QuadTreeMemento(0, worldBounds, e => e.Bounds); - Elements.CollectionChanged += OnElementsCollectionChanged; - } + Elements.CollectionChanged += OnElementsCollectionChanged; + } - public string Name - { - get => name; - set => this.RaiseAndSetIfChanged(ref name, value); - } + public string Name + { + get => name; + set => this.RaiseAndSetIfChanged(ref name, value); + } - public ObservableCollection Elements { get; } = []; + public ObservableCollection Elements { get; } = []; - public bool IsVisible - { - get => isVisible; - set => this.RaiseAndSetIfChanged(ref isVisible, value); - } + public bool IsVisible + { + get => isVisible; + set => this.RaiseAndSetIfChanged(ref isVisible, value); + } - public bool IsLocked - { - get => isLocked; - set => this.RaiseAndSetIfChanged(ref isLocked, value); - } + public bool IsLocked + { + get => isLocked; + set => this.RaiseAndSetIfChanged(ref isLocked, value); + } - public MaskingMode MaskingMode - { - get => maskingMode; - set => this.RaiseAndSetIfChanged(ref maskingMode, value); - } + public MaskingMode MaskingMode + { + get => maskingMode; + set => this.RaiseAndSetIfChanged(ref maskingMode, value); + } - private void OnElementsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private void OnElementsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) { - if (e.NewItems != null) + // Find the maximum existing ZIndex in the layer from elements whose ZIndex is not 0 + // This caters to cases where ZIndex might have been explicitly set (non-zero) + int maxZIndex = -1; // Default to -1 so first element gets ZIndex 0 + if (Elements.Any()) { - // Find the maximum existing ZIndex in the layer from elements whose ZIndex is not 0 - // This caters to cases where ZIndex might have been explicitly set (non-zero) - int maxZIndex = -1; // Default to -1 so first element gets ZIndex 0 - if (Elements.Any()) - { - maxZIndex = Elements.Where(el => !e.NewItems.Contains(el)) // Exclude newly added items themselves - .DefaultIfEmpty(new DrawablePath { ZIndex = -1, Path = new SKPath() }) // Provide a default if no other elements - .Max(el => el.ZIndex); - } + maxZIndex = Elements.Where(el => !e.NewItems.Contains(el)) // Exclude newly added items themselves + .DefaultIfEmpty(new DrawablePath { ZIndex = -1, Path = new SKPath() }) // Provide a default if no other elements + .Max(el => el.ZIndex); + } - foreach (IDrawableElement item in e.NewItems) + foreach (IDrawableElement item in e.NewItems) + { + // Only assign ZIndex if it hasn't been explicitly set (i.e., it's default 0) + if (item.ZIndex == 0) { - // Only assign ZIndex if it hasn't been explicitly set (i.e., it's default 0) - if (item.ZIndex == 0) - { - item.ZIndex = maxZIndex + 1; // Assign a ZIndex higher than any existing element - maxZIndex = item.ZIndex; // Update maxZIndex for subsequent new items in this batch - } + item.ZIndex = maxZIndex + 1; // Assign a ZIndex higher than any existing element + maxZIndex = item.ZIndex; // Update maxZIndex for subsequent new items in this batch } } - - RebuildQuadTree(); } - private void RebuildQuadTree() - { - quadTree.Clear(); - foreach (var element in Elements) - { - quadTree.Insert(element); - } - } + RebuildQuadTree(); + } - public static void InvalidateCache() + private void RebuildQuadTree() + { + quadTree.Clear(); + foreach (var element in Elements) { - // No cache to invalidate + quadTree.Insert(element); } + } - public void Draw(SKCanvas canvas) - { - // Get visible rect in World Coordinates (Local Clip Bounds handles the matrix transform automatically) - var visibleRect = canvas.LocalClipBounds; + public static void InvalidateCache() + { + // No cache to invalidate + } + + public void Draw(SKCanvas canvas) + { + // Get visible rect in World Coordinates (Local Clip Bounds handles the matrix transform automatically) + var visibleRect = canvas.LocalClipBounds; - // Use QuadTree to find elements that are potentially visible - var visibleElements = new List(); - quadTree.Retrieve(visibleElements, visibleRect); + // Use QuadTree to find elements that are potentially visible + var visibleElements = new List(); + quadTree.Retrieve(visibleElements, visibleRect); - // Sort by ZIndex to ensure correct draw order - visibleElements.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); + // Sort by ZIndex to ensure correct draw order + visibleElements.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); - foreach (var element in visibleElements) + foreach (var element in visibleElements) + { + if (element.IsVisible) { - if (element.IsVisible) + // Double check intersection just in case QuadTree is loose + if (element.Bounds.IntersectsWith(visibleRect)) { - // Double check intersection just in case QuadTree is loose - if (element.Bounds.IntersectsWith(visibleRect)) - { - element.Draw(canvas); - } + element.Draw(canvas); } } } + } - public Layer Clone() + public Layer Clone() + { + var clone = new Layer { - var clone = new Layer - { - Name = Name, - IsVisible = IsVisible, - IsLocked = IsLocked, - MaskingMode = MaskingMode - }; - - foreach (var element in Elements) - { - clone.Elements.Add(element.Clone()); - } + Name = Name, + IsVisible = IsVisible, + IsLocked = IsLocked, + MaskingMode = MaskingMode + }; - return clone; + foreach (var element in Elements) + { + clone.Elements.Add(element.Clone()); } + + return clone; } } diff --git a/Logic/Models/MaskingMode.cs b/Logic/Models/MaskingMode.cs index b354fcf..4a2ac28 100644 --- a/Logic/Models/MaskingMode.cs +++ b/Logic/Models/MaskingMode.cs @@ -21,12 +21,11 @@ * */ -namespace LunaDraw.Logic.Models -{ - public enum MaskingMode - { - None, - Clip, - Alpha - } -} +namespace LunaDraw.Logic.Models; + + public enum MaskingMode + { + None, + Clip, + Alpha + } diff --git a/Logic/Models/NavigationModel.cs b/Logic/Models/NavigationModel.cs index 8f91cdb..eb0c2b1 100644 --- a/Logic/Models/NavigationModel.cs +++ b/Logic/Models/NavigationModel.cs @@ -24,36 +24,35 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Models -{ - public class NavigationModel : ReactiveObject - { - private SKMatrix viewMatrix = SKMatrix.CreateIdentity(); +namespace LunaDraw.Logic.Models; - // Single source of truth - this is what gets applied to the canvas - public SKMatrix ViewMatrix - { - get => viewMatrix; - set => this.RaiseAndSetIfChanged(ref viewMatrix, value); - } + public class NavigationModel : ReactiveObject + { + private SKMatrix viewMatrix = SKMatrix.CreateIdentity(); - private float canvasWidth; - public float CanvasWidth - { - get => canvasWidth; - set => this.RaiseAndSetIfChanged(ref canvasWidth, value); - } + // Single source of truth - this is what gets applied to the canvas + public SKMatrix ViewMatrix + { + get => viewMatrix; + set => this.RaiseAndSetIfChanged(ref viewMatrix, value); + } - private float canvasHeight; - public float CanvasHeight - { - get => canvasHeight; - set => this.RaiseAndSetIfChanged(ref canvasHeight, value); - } + private float canvasWidth; + public float CanvasWidth + { + get => canvasWidth; + set => this.RaiseAndSetIfChanged(ref canvasWidth, value); + } - public void Reset() - { - ViewMatrix = SKMatrix.CreateIdentity(); - } - } -} \ No newline at end of file + private float canvasHeight; + public float CanvasHeight + { + get => canvasHeight; + set => this.RaiseAndSetIfChanged(ref canvasHeight, value); + } + + public void Reset() + { + ViewMatrix = SKMatrix.CreateIdentity(); + } + } \ No newline at end of file diff --git a/Logic/Models/ToolContext.cs b/Logic/Models/ToolContext.cs index f647069..24c99c3 100644 --- a/Logic/Models/ToolContext.cs +++ b/Logic/Models/ToolContext.cs @@ -21,36 +21,35 @@ * */ -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Provides context information to drawing tools, including current drawing properties and access to elements. +/// +public class ToolContext { - /// - /// Provides context information to drawing tools, including current drawing properties and access to elements. - /// - public class ToolContext - { - public required Layer CurrentLayer { get; set; } - public SKColor StrokeColor { get; set; } - public SKColor? FillColor { get; set; } - public float StrokeWidth { get; set; } - public byte Opacity { get; set; } - public byte Flow { get; set; } - public float Spacing { get; set; } - public required BrushShape BrushShape { get; set; } - public required IEnumerable AllElements { get; set; } - public IEnumerable Layers { get; set; } = []; - public required SelectionObserver SelectionObserver { get; set; } - public float Scale { get; set; } = 1.0f; - public bool IsGlowEnabled { get; init; } - public SKColor GlowColor { get; init; } - public float GlowRadius { get; init; } - public bool IsRainbowEnabled { get; init; } - public float ScatterRadius { get; init; } - public float SizeJitter { get; init; } - public float AngleJitter { get; init; } - public float HueJitter { get; init; } - public SKMatrix CanvasMatrix { get; set; } = SKMatrix.CreateIdentity(); - } + public required Layer CurrentLayer { get; set; } + public SKColor StrokeColor { get; set; } + public SKColor? FillColor { get; set; } + public float StrokeWidth { get; set; } + public byte Opacity { get; set; } + public byte Flow { get; set; } + public float Spacing { get; set; } + public required BrushShape BrushShape { get; set; } + public required IEnumerable AllElements { get; set; } + public IEnumerable Layers { get; set; } = []; + public required SelectionObserver SelectionObserver { get; set; } + public float Scale { get; set; } = 1.0f; + public bool IsGlowEnabled { get; init; } + public SKColor GlowColor { get; init; } + public float GlowRadius { get; init; } + public bool IsRainbowEnabled { get; init; } + public float ScatterRadius { get; init; } + public float SizeJitter { get; init; } + public float AngleJitter { get; init; } + public float HueJitter { get; init; } + public SKMatrix CanvasMatrix { get; set; } = SKMatrix.CreateIdentity(); } diff --git a/Logic/Tools/EllipseTool.cs b/Logic/Tools/EllipseTool.cs index 2399c71..31f6a70 100644 --- a/Logic/Tools/EllipseTool.cs +++ b/Logic/Tools/EllipseTool.cs @@ -25,33 +25,32 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EllipseTool(IMessageBus messageBus) : ShapeTool(messageBus) { - public class EllipseTool(IMessageBus messageBus) : ShapeTool(messageBus) - { - public override string Name => "Ellipse"; - public override ToolType Type => ToolType.Ellipse; + public override string Name => "Ellipse"; + public override ToolType Type => ToolType.Ellipse; - protected override DrawableEllipse CreateShape(ToolContext context) + protected override DrawableEllipse CreateShape(ToolContext context) + { + return new DrawableEllipse { - return new DrawableEllipse - { - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity, - FillColor = context.FillColor - }; - } + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity, + FillColor = context.FillColor + }; + } - protected override void UpdateShape(DrawableEllipse shape, SKRect bounds, SKMatrix transform) - { - shape.TransformMatrix = transform; - shape.Oval = bounds; - } + protected override void UpdateShape(DrawableEllipse shape, SKRect bounds, SKMatrix transform) + { + shape.TransformMatrix = transform; + shape.Oval = bounds; + } - protected override bool IsShapeValid(DrawableEllipse shape) - { - return shape.Oval.Width > 0 || shape.Oval.Height > 0; - } + protected override bool IsShapeValid(DrawableEllipse shape) + { + return shape.Oval.Width > 0 || shape.Oval.Height > 0; } } \ No newline at end of file diff --git a/Logic/Tools/EraserBrushTool.cs b/Logic/Tools/EraserBrushTool.cs index a799448..197e3ff 100644 --- a/Logic/Tools/EraserBrushTool.cs +++ b/Logic/Tools/EraserBrushTool.cs @@ -21,349 +21,350 @@ * */ +using LunaDraw.Logic.Extensions; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; - +using LunaDraw.Logic.Utils; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EraserBrushTool(IMessageBus messageBus, IPreferencesFacade preferencesFacade) : IDrawingTool { - public class EraserBrushTool(IMessageBus messageBus) : IDrawingTool + public string Name => "Eraser"; + public ToolType Type => ToolType.Eraser; + + private SKPath? currentPath; + private DrawablePath? currentDrawablePath; + private readonly IMessageBus messageBus = messageBus; + private readonly IPreferencesFacade preferencesFacade = preferencesFacade; + + public void OnTouchPressed(SKPoint point, ToolContext context) { - public string Name => "Eraser"; - public ToolType Type => ToolType.Eraser; + if (context.CurrentLayer?.IsLocked == true) return; - private SKPath? currentPath; - private DrawablePath? currentDrawablePath; - private readonly IMessageBus messageBus = messageBus; + currentPath = new SKPath(); + currentPath.MoveTo(point); - public void OnTouchPressed(SKPoint point, ToolContext context) + currentDrawablePath = new DrawablePath { - if (context.CurrentLayer?.IsLocked == true) return; + Path = currentPath, + StrokeColor = preferencesFacade.GetCanvasBackgroundColor(), // Visual preview color + StrokeWidth = context.StrokeWidth * 2, // Eraser usually wider + Opacity = 255, + BlendMode = SKBlendMode.SrcOver, + ZIndex = context.CurrentLayer?.Elements.Count > 0 ? context.CurrentLayer.Elements.Max(e => e.ZIndex) + 1 : 0 + }; + + context.CurrentLayer?.Elements.Add(currentDrawablePath); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - currentPath = new SKPath(); - currentPath.MoveTo(point); + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (currentPath == null || context.CurrentLayer?.IsLocked == true) return; - currentDrawablePath = new DrawablePath - { - Path = currentPath, - StrokeColor = SKColors.White, // Visual preview color - StrokeWidth = context.StrokeWidth * 2, // Eraser usually wider - Opacity = 255, - BlendMode = SKBlendMode.SrcOver, - ZIndex = context.CurrentLayer?.Elements.Count > 0 ? context.CurrentLayer.Elements.Max(e => e.ZIndex) + 1 : 0 - }; - - context.CurrentLayer?.Elements.Add(currentDrawablePath); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPath.LineTo(point); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (currentPath == null || context.CurrentLayer?.IsLocked == true) return; + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (currentPath == null || context.CurrentLayer == null) return; - currentPath.LineTo(point); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPath.LineTo(point); - public void OnTouchReleased(SKPoint point, ToolContext context) + // Remove the temporary "global" eraser path + if (currentDrawablePath != null) { - if (currentPath == null || context.CurrentLayer == null) return; + context.CurrentLayer.Elements.Remove(currentDrawablePath); + } - currentPath.LineTo(point); + // Convert stroke to fill path (outline) for operations + using var strokePaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = context.StrokeWidth * 2, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + using var eraserOutline = new SKPath(); + strokePaint.GetFillPath(currentPath, eraserOutline); + + var elements = context.CurrentLayer.Elements.ToList(); + var modified = false; + var elementsToRemove = new List(); + var elementsToAdd = new List(); + + foreach (var element in elements) + { + if (element == currentDrawablePath) continue; + if (!element.IsVisible) continue; - // Remove the temporary "global" eraser path - if (currentDrawablePath != null) + // Determine if this is a "pure stroke" (like a freehand line or line shape) vs a "filled shape" + bool isPureStroke; + if (element is DrawablePath dp) { - context.CurrentLayer.Elements.Remove(currentDrawablePath); + // A DrawablePath is a pure stroke only if it is explicitly NOT filled. + // If it has a FillShader (e.g. erased image) or IsFilled=true, it is a shape. + isPureStroke = !dp.IsFilled; } - - // Convert stroke to fill path (outline) for operations - using var strokePaint = new SKPaint + else if (element is DrawableLine) { - Style = SKPaintStyle.Stroke, - StrokeWidth = context.StrokeWidth * 2, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - using var eraserOutline = new SKPath(); - strokePaint.GetFillPath(currentPath, eraserOutline); - - var elements = context.CurrentLayer.Elements.ToList(); - var modified = false; - var elementsToRemove = new List(); - var elementsToAdd = new List(); - - foreach (var element in elements) + isPureStroke = true; + } + else if (element is DrawableImage) + { + isPureStroke = false; + } + else { - if (element == currentDrawablePath) continue; - if (!element.IsVisible) continue; + // For other shapes (Rect, Ellipse), they are pure strokes if they have no fill. + isPureStroke = element.FillColor == null; + } - // Determine if this is a "pure stroke" (like a freehand line or line shape) vs a "filled shape" - bool isPureStroke; - if (element is DrawablePath dp) - { - // A DrawablePath is a pure stroke only if it is explicitly NOT filled. - // If it has a FillShader (e.g. erased image) or IsFilled=true, it is a shape. - isPureStroke = !dp.IsFilled; - } - else if (element is DrawableLine) - { - isPureStroke = true; - } - else if (element is DrawableImage) - { - isPureStroke = false; - } - else - { - // For other shapes (Rect, Ellipse), they are pure strokes if they have no fill. - isPureStroke = element.FillColor == null; - } + if (element is DrawableStamps stamps) + { + var remainingPoints = new List(); + var remainingRotations = new List(); + var stampModified = false; - if (element is DrawableStamps stamps) - { - var remainingPoints = new List(); - var remainingRotations = new List(); - var stampModified = false; + // Iterate through detailed instances to handle geometry & color accurately + var instances = stamps.GetDetailedPaths().ToList(); - // Iterate through detailed instances to handle geometry & color accurately - var instances = stamps.GetDetailedPaths().ToList(); + // We need to match instances back to original points by index + for (int i = 0; i < instances.Count; i++) + { + var (stampPath, stampColor) = instances[i]; + var originalPoint = stamps.Points[i]; - // We need to match instances back to original points by index - for (int i = 0; i < instances.Count; i++) + using (stampPath) { - var (stampPath, stampColor) = instances[i]; - var originalPoint = stamps.Points[i]; + // Check intersection + using var intersection = new SKPath(); + bool intersects = eraserOutline.Op(stampPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty; - using (stampPath) + if (!intersects) { - // Check intersection - using var intersection = new SKPath(); - bool intersects = eraserOutline.Op(stampPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty; - - if (!intersects) + // Completely untouched, keep as a stamp + remainingPoints.Add(originalPoint); + if (stamps.Rotations != null && i < stamps.Rotations.Count) { - // Completely untouched, keep as a stamp - remainingPoints.Add(originalPoint); - if (stamps.Rotations != null && i < stamps.Rotations.Count) - { - remainingRotations.Add(stamps.Rotations[i]); - } + remainingRotations.Add(stamps.Rotations[i]); } - else - { - // Touched (Partial or Full erase) -> Convert to Path(s) or Destroy - stampModified = true; + } + else + { + // Touched (Partial or Full erase) -> Convert to Path(s) or Destroy + stampModified = true; - using var resultPath = new SKPath(); - if (stampPath.Op(eraserOutline, SKPathOp.Difference, resultPath) && !resultPath.IsEmpty) + using var resultPath = new SKPath(); + if (stampPath.Op(eraserOutline, SKPathOp.Difference, resultPath) && !resultPath.IsEmpty) + { + // Create a new DrawablePath for the fragment + var newFragment = new DrawablePath { - // Create a new DrawablePath for the fragment - var newFragment = new DrawablePath - { - Path = new SKPath(resultPath), // Copy the result - TransformMatrix = SKMatrix.CreateIdentity(), // Logic was already applied in GetDetailedPaths (including TransformMatrix) - IsVisible = stamps.IsVisible, - Opacity = (byte)(stamps.Opacity * stamps.Flow / 255f), // Combine Opacity and Flow - ZIndex = stamps.ZIndex, - IsSelected = false, // Fragments shouldn't inherit selection immediately - IsGlowEnabled = stamps.IsGlowEnabled, - GlowColor = stamps.GlowColor, - GlowRadius = stamps.GlowRadius, - IsFilled = true, // Stamps are filled shapes - StrokeWidth = 0, - StrokeColor = stampColor, // Use the specific jittered color - FillColor = null, // Logic implies "Filled Stroke" behavior for consistency with other paths - BlendMode = stamps.BlendMode - }; - - // Note: 'stampColor' is used as StrokeColor with IsFilled=true because - // in the generic path logic above (lines 169-172), eroded strokes become filled blobs - // where StrokeColor is preserved. DrawableStamps usually render as fills of the 'StrokeColor'. - - elementsToAdd.Add(newFragment); - } - // If resultPath is empty, it was fully erased. Do nothing (it's gone). + Path = new SKPath(resultPath), // Copy the result + TransformMatrix = SKMatrix.CreateIdentity(), // Logic was already applied in GetDetailedPaths (including TransformMatrix) + IsVisible = stamps.IsVisible, + Opacity = (byte)(stamps.Opacity * stamps.Flow / 255f), // Combine Opacity and Flow + ZIndex = stamps.ZIndex, + IsSelected = false, // Fragments shouldn't inherit selection immediately + IsGlowEnabled = stamps.IsGlowEnabled, + GlowColor = stamps.GlowColor, + GlowRadius = stamps.GlowRadius, + IsFilled = true, // Stamps are filled shapes + StrokeWidth = 0, + StrokeColor = stampColor, // Use the specific jittered color + FillColor = null, // Logic implies "Filled Stroke" behavior for consistency with other paths + BlendMode = stamps.BlendMode + }; + + // Note: 'stampColor' is used as StrokeColor with IsFilled=true because + // in the generic path logic above (lines 169-172), eroded strokes become filled blobs + // where StrokeColor is preserved. DrawableStamps usually render as fills of the 'StrokeColor'. + + elementsToAdd.Add(newFragment); } + // If resultPath is empty, it was fully erased. Do nothing (it's gone). } } + } - if (stampModified) + if (stampModified) + { + if (remainingPoints.Count == 0) { - if (remainingPoints.Count == 0) - { - elementsToRemove.Add(element); - } - else - { - // Create a new Stamps object for the remaining untouched stamps - var newStamps = (DrawableStamps)stamps.Clone(); - newStamps.Points = remainingPoints; - newStamps.Rotations = remainingRotations; + elementsToRemove.Add(element); + } + else + { + // Create a new Stamps object for the remaining untouched stamps + var newStamps = (DrawableStamps)stamps.Clone(); + newStamps.Points = remainingPoints; + newStamps.Rotations = remainingRotations; - elementsToRemove.Add(element); - elementsToAdd.Add(newStamps); - } - modified = true; + elementsToRemove.Add(element); + elementsToAdd.Add(newStamps); } - continue; + modified = true; } + continue; + } - SKPath elementPath; - if (isPureStroke) - { - // OLD BEHAVIOR for strokes: Get the visual outline - elementPath = element.GetPath(); - } - else - { - // NEW BEHAVIOR for shapes: Get the geometry contour - elementPath = element.GetGeometryPath(); - } + SKPath elementPath; + if (isPureStroke) + { + // OLD BEHAVIOR for strokes: Get the visual outline + elementPath = element.GetPath(); + } + else + { + // NEW BEHAVIOR for shapes: Get the geometry contour + elementPath = element.GetGeometryPath(); + } - using (elementPath) + using (elementPath) + { + // Check for intersection first (optimization) + using var intersection = new SKPath(); + if (eraserOutline.Op(elementPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty) { - // Check for intersection first (optimization) - using var intersection = new SKPath(); - if (eraserOutline.Op(elementPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty) + // Calculate the difference (Element - Eraser) + var resultPath = new SKPath(); + if (elementPath.Op(eraserOutline, SKPathOp.Difference, resultPath)) { - // Calculate the difference (Element - Eraser) - var resultPath = new SKPath(); - if (elementPath.Op(eraserOutline, SKPathOp.Difference, resultPath)) + if (resultPath.IsEmpty) { - if (resultPath.IsEmpty) + // Element completely erased + elementsToRemove.Add(element); + } + else + { + // Create new element with the remaining geometry + var newElement = new DrawablePath + { + Path = resultPath, + TransformMatrix = SKMatrix.CreateIdentity(), // We might need to adjust this depending on how GetGeometryPath works + IsVisible = element.IsVisible, + Opacity = element.Opacity, + ZIndex = element.ZIndex, + IsSelected = element.IsSelected, + IsGlowEnabled = element.IsGlowEnabled, + GlowColor = element.GlowColor, + GlowRadius = element.GlowRadius + }; + + if (isPureStroke) { - // Element completely erased - elementsToRemove.Add(element); + // Result of eroding a stroke is a filled shape (the leftover pieces of the outline) + newElement.StrokeWidth = 0; + newElement.IsFilled = true; + newElement.StrokeColor = element.StrokeColor; + newElement.FillColor = null; } else { - // Create new element with the remaining geometry - var newElement = new DrawablePath - { - Path = resultPath, - TransformMatrix = SKMatrix.CreateIdentity(), // We might need to adjust this depending on how GetGeometryPath works - IsVisible = element.IsVisible, - Opacity = element.Opacity, - ZIndex = element.ZIndex, - IsSelected = element.IsSelected, - IsGlowEnabled = element.IsGlowEnabled, - GlowColor = element.GlowColor, - GlowRadius = element.GlowRadius - }; - - if (isPureStroke) + // Result of eroding a shape preserves shape properties + newElement.StrokeWidth = element.StrokeWidth; + newElement.StrokeColor = element.StrokeColor; + newElement.FillColor = element.FillColor; + newElement.IsFilled = true; + + // TRANSFORM FIX: + // Put the path back into the original element's coordinate space + if (element.TransformMatrix.TryInvert(out var inverseMatrix)) { - // Result of eroding a stroke is a filled shape (the leftover pieces of the outline) - newElement.StrokeWidth = 0; - newElement.IsFilled = true; - newElement.StrokeColor = element.StrokeColor; - newElement.FillColor = null; + newElement.Path.Transform(inverseMatrix); + newElement.TransformMatrix = element.TransformMatrix; } - else + + // Handle Image Shader creation specifically here + if (element is DrawableImage iamge) { - // Result of eroding a shape preserves shape properties - newElement.StrokeWidth = element.StrokeWidth; - newElement.StrokeColor = element.StrokeColor; - newElement.FillColor = element.FillColor; + // Since we reverted to Local Space, the shader is simple (Identity matrix) + var shader = SKShader.CreateBitmap(iamge.Bitmap, SKShaderTileMode.Decal, SKShaderTileMode.Decal); + newElement.FillShader = shader; newElement.IsFilled = true; - // TRANSFORM FIX: - // Put the path back into the original element's coordinate space - if (element.TransformMatrix.TryInvert(out var inverseMatrix)) - { - newElement.Path.Transform(inverseMatrix); - newElement.TransformMatrix = element.TransformMatrix; - } - - // Handle Image Shader creation specifically here - if (element is DrawableImage iamge) - { - // Since we reverted to Local Space, the shader is simple (Identity matrix) - var shader = SKShader.CreateBitmap(iamge.Bitmap, SKShaderTileMode.Decal, SKShaderTileMode.Decal); - newElement.FillShader = shader; - newElement.IsFilled = true; - - // Ensure we carry over opacity and stuff - newElement.Opacity = iamge.Opacity; - } - else if (element is DrawablePath oldPath) - { - newElement.FillShader = oldPath.FillShader; - } + // Ensure we carry over opacity and stuff + newElement.Opacity = iamge.Opacity; } - - if (element is DrawablePath originalPath) + else if (element is DrawablePath oldPath) { - newElement.BlendMode = originalPath.BlendMode; + newElement.FillShader = oldPath.FillShader; } + } - elementsToRemove.Add(element); - elementsToAdd.Add(newElement); + if (element is DrawablePath originalPath) + { + newElement.BlendMode = originalPath.BlendMode; } - modified = true; + + elementsToRemove.Add(element); + elementsToAdd.Add(newElement); } + modified = true; } } } + } - if (modified) - { - var finalElements = new List(); + if (modified) + { + var finalElements = new List(); - // Add all elements that were NOT removed - foreach (var element in elements) // 'elements' is a copy from the start of the method + // Add all elements that were NOT removed + foreach (var element in elements) // 'elements' is a copy from the start of the method + { + if (!elementsToRemove.Contains(element)) { - if (!elementsToRemove.Contains(element)) - { - finalElements.Add(element); - } + finalElements.Add(element); } + } - // Add all newly created elements (fragments from erased items) - finalElements.AddRange(elementsToAdd); + // Add all newly created elements (fragments from erased items) + finalElements.AddRange(elementsToAdd); - // Clear the ObservableCollection once, then re-populate it - context.CurrentLayer.Elements.Clear(); - foreach (var item in finalElements.OrderBy(e => e.ZIndex)) // Add in sorted ZIndex order - { - context.CurrentLayer.Elements.Add(item); - } - - // Normalize Z-indices on the actual collection elements *after* they are in the collection - var currentLayerElementsInCollection = context.CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); - for (int i = 0; i < currentLayerElementsInCollection.Count; i++) - { - currentLayerElementsInCollection[i].ZIndex = i; - } - - messageBus.SendMessage(new DrawingStateChangedMessage()); - messageBus.SendMessage(new CanvasInvalidateMessage()); + // Clear the ObservableCollection once, then re-populate it + context.CurrentLayer.Elements.Clear(); + foreach (var item in finalElements.OrderBy(e => e.ZIndex)) // Add in sorted ZIndex order + { + context.CurrentLayer.Elements.Add(item); } - messageBus.SendMessage(new CanvasInvalidateMessage()); - currentPath = null; - currentDrawablePath = null; - } - - public void OnTouchCancelled(ToolContext context) - { - if (currentDrawablePath != null && context.CurrentLayer != null) + // Normalize Z-indices on the actual collection elements *after* they are in the collection + var currentLayerElementsInCollection = context.CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); + for (int i = 0; i < currentLayerElementsInCollection.Count; i++) { - context.CurrentLayer.Elements.Remove(currentDrawablePath); + currentLayerElementsInCollection[i].ZIndex = i; } - currentPath = null; - currentDrawablePath = null; + messageBus.SendMessage(new DrawingStateChangedMessage()); messageBus.SendMessage(new CanvasInvalidateMessage()); } + messageBus.SendMessage(new CanvasInvalidateMessage()); - public void DrawPreview(SKCanvas canvas, ToolContext context) + currentPath = null; + currentDrawablePath = null; + } + + public void OnTouchCancelled(ToolContext context) + { + if (currentDrawablePath != null && context.CurrentLayer != null) { - // Optional: Draw a circle cursor for eraser size + context.CurrentLayer.Elements.Remove(currentDrawablePath); } + + currentPath = null; + currentDrawablePath = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + // Optional: Draw a circle cursor for eraser size } } \ No newline at end of file diff --git a/Logic/Tools/EraserTool.cs b/Logic/Tools/EraserTool.cs index 2085330..af5e4f5 100644 --- a/Logic/Tools/EraserTool.cs +++ b/Logic/Tools/EraserTool.cs @@ -23,65 +23,63 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EraserTool(IMessageBus messageBus) : IDrawingTool { - public class EraserTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Eraser"; - public ToolType Type => ToolType.Eraser; + public string Name => "Eraser"; + public ToolType Type => ToolType.Eraser; - private bool isErasing; - private readonly IMessageBus messageBus = messageBus; + private bool isErasing; + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - isErasing = true; - Erase(point, context); - } - - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (isErasing) - { - Erase(point, context); - } - } + public void OnTouchPressed(SKPoint point, ToolContext context) + { + isErasing = true; + Erase(point, context); + } - public void OnTouchReleased(SKPoint point, ToolContext context) + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (isErasing) { - isErasing = false; + Erase(point, context); } + } - public void OnTouchCancelled(ToolContext context) - { - isErasing = false; - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + isErasing = false; + } - private void Erase(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public void OnTouchCancelled(ToolContext context) + { + isErasing = false; + } - var hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); + private void Erase(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - if (hitElement != null && context.CurrentLayer != null) - { - context.CurrentLayer.Elements.Remove(hitElement); - messageBus.SendMessage(new DrawingStateChangedMessage()); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - } + var hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); - public void DrawPreview(SKCanvas canvas, ToolContext context) + if (hitElement != null && context.CurrentLayer != null) { + context.CurrentLayer.Elements.Remove(hitElement); + messageBus.SendMessage(new DrawingStateChangedMessage()); + messageBus.SendMessage(new CanvasInvalidateMessage()); } } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + } } \ No newline at end of file diff --git a/Logic/Tools/FillTool.cs b/Logic/Tools/FillTool.cs index f3c3a18..d107858 100644 --- a/Logic/Tools/FillTool.cs +++ b/Logic/Tools/FillTool.cs @@ -23,51 +23,49 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools -{ - public class FillTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Fill"; - public ToolType Type => ToolType.Fill; - private readonly IMessageBus messageBus = messageBus; +namespace LunaDraw.Logic.Tools; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; +public class FillTool(IMessageBus messageBus) : IDrawingTool +{ + public string Name => "Fill"; + public ToolType Type => ToolType.Fill; + private readonly IMessageBus messageBus = messageBus; - var hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - if (hitElement != null) - { - hitElement.FillColor = context.FillColor; - messageBus.SendMessage(new CanvasInvalidateMessage()); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } - } + var hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); - public void OnTouchMoved(SKPoint point, ToolContext context) + if (hitElement != null) { + hitElement.FillColor = context.FillColor; + messageBus.SendMessage(new CanvasInvalidateMessage()); + messageBus.SendMessage(new DrawingStateChangedMessage()); } + } - public void OnTouchReleased(SKPoint point, ToolContext context) - { - } + public void OnTouchMoved(SKPoint point, ToolContext context) + { + } - public void OnTouchCancelled(ToolContext context) - { - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - } + public void OnTouchCancelled(ToolContext context) + { + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { } } diff --git a/Logic/Tools/FreehandTool.cs b/Logic/Tools/FreehandTool.cs index 80cc933..e8eda5a 100644 --- a/Logic/Tools/FreehandTool.cs +++ b/Logic/Tools/FreehandTool.cs @@ -23,228 +23,226 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class FreehandTool(IMessageBus messageBus) : IDrawingTool { - public class FreehandTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Stamps"; - public ToolType Type => ToolType.Freehand; + public string Name => "Stamps"; + public ToolType Type => ToolType.Freehand; - private List<(SKPoint Point, float Rotation)>? currentPoints; - private SKPoint lastStampPoint; - private bool isDrawing; - private readonly Random random = new Random(); - private readonly IMessageBus messageBus = messageBus; + private List<(SKPoint Point, float Rotation)>? currentPoints; + private SKPoint lastStampPoint; + private bool isDrawing; + private readonly Random random = new Random(); + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - currentPoints = - [ - // Add initial point with default 0 rotation - (point, 0f), - ]; - lastStampPoint = point; - isDrawing = true; + currentPoints = + [ + // Add initial point with default 0 rotation + (point, 0f), + ]; + lastStampPoint = point; + isDrawing = true; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (!isDrawing || context.CurrentLayer?.IsLocked == true || currentPoints == null) return; + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (!isDrawing || context.CurrentLayer?.IsLocked == true || currentPoints == null) return; - float spacingPixels = context.Spacing * context.StrokeWidth; - if (spacingPixels < 1) spacingPixels = 1; + float spacingPixels = context.Spacing * context.StrokeWidth; + if (spacingPixels < 1) spacingPixels = 1; - var vector = point - lastStampPoint; - float distance = vector.Length; + var vector = point - lastStampPoint; + float distance = vector.Length; - if (distance >= spacingPixels) + if (distance >= spacingPixels) + { + var direction = vector; + // Normalize manually to avoid issues with zero length + if (distance > 0) { - var direction = vector; - // Normalize manually to avoid issues with zero length - if (distance > 0) - { - float invLength = 1.0f / distance; - direction = new SKPoint(direction.X * invLength, direction.Y * invLength); - } + float invLength = 1.0f / distance; + direction = new SKPoint(direction.X * invLength, direction.Y * invLength); + } - // Calculate angle for this segment - float angle = (float)(Math.Atan2(vector.Y, vector.X) * 180.0 / Math.PI); + // Calculate angle for this segment + float angle = (float)(Math.Atan2(vector.Y, vector.X) * 180.0 / Math.PI); + + int steps = (int)(distance / spacingPixels); + for (int i = 0; i < steps; i++) + { + var idealPoint = lastStampPoint + new SKPoint(direction.X * spacingPixels, direction.Y * spacingPixels); - int steps = (int)(distance / spacingPixels); - for (int i = 0; i < steps; i++) + var finalPoint = idealPoint; + if (context.ScatterRadius > 0) { - var idealPoint = lastStampPoint + new SKPoint(direction.X * spacingPixels, direction.Y * spacingPixels); - - var finalPoint = idealPoint; - if (context.ScatterRadius > 0) - { - // Random scatter in a circle - double rndAngle = random.NextDouble() * Math.PI * 2; - double r = Math.Sqrt(random.NextDouble()) * context.ScatterRadius; // Sqrt for uniform distribution - finalPoint += new SKPoint((float)(r * Math.Cos(rndAngle)), (float)(r * Math.Sin(rndAngle))); - } - - currentPoints.Add((finalPoint, angle)); - lastStampPoint = idealPoint; + // Random scatter in a circle + double rndAngle = random.NextDouble() * Math.PI * 2; + double r = Math.Sqrt(random.NextDouble()) * context.ScatterRadius; // Sqrt for uniform distribution + finalPoint += new SKPoint((float)(r * Math.Cos(rndAngle)), (float)(r * Math.Sin(rndAngle))); } - messageBus.SendMessage(new CanvasInvalidateMessage()); - + currentPoints.Add((finalPoint, angle)); + lastStampPoint = idealPoint; } + + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + } + + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (!isDrawing || context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentPoints == null) return; - public void OnTouchReleased(SKPoint point, ToolContext context) + if (currentPoints.Count > 0) { - if (!isDrawing || context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentPoints == null) return; + var points = currentPoints.Select(p => p.Point).ToList(); + var rotations = currentPoints.Select(p => p.Rotation).ToList(); - if (currentPoints.Count > 0) + var element = new DrawableStamps { - var points = currentPoints.Select(p => p.Point).ToList(); - var rotations = currentPoints.Select(p => p.Rotation).ToList(); - - var element = new DrawableStamps - { - Points = points, - Rotations = rotations, - Shape = context.BrushShape, - Size = context.StrokeWidth, - Flow = context.Flow, - Opacity = context.Opacity, - StrokeColor = context.StrokeColor, - IsGlowEnabled = context.IsGlowEnabled, - GlowColor = context.GlowColor, - GlowRadius = context.GlowRadius, - IsRainbowEnabled = context.IsRainbowEnabled, - SizeJitter = context.SizeJitter, - AngleJitter = context.AngleJitter, - HueJitter = context.HueJitter - }; - - context.CurrentLayer.Elements.Add(element); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } + Points = points, + Rotations = rotations, + Shape = context.BrushShape, + Size = context.StrokeWidth, + Flow = context.Flow, + Opacity = context.Opacity, + StrokeColor = context.StrokeColor, + IsGlowEnabled = context.IsGlowEnabled, + GlowColor = context.GlowColor, + GlowRadius = context.GlowRadius, + IsRainbowEnabled = context.IsRainbowEnabled, + SizeJitter = context.SizeJitter, + AngleJitter = context.AngleJitter, + HueJitter = context.HueJitter + }; - currentPoints = null; - isDrawing = false; - messageBus.SendMessage(new CanvasInvalidateMessage()); + context.CurrentLayer.Elements.Add(element); + messageBus.SendMessage(new DrawingStateChangedMessage()); } - public void OnTouchCancelled(ToolContext context) - { - currentPoints = null; - isDrawing = false; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPoints = null; + isDrawing = false; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (currentPoints == null || currentPoints.Count == 0) return; + public void OnTouchCancelled(ToolContext context) + { + currentPoints = null; + isDrawing = false; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - // Get current shape from context - var shape = context.BrushShape; - if (shape?.Path == null) return; + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (currentPoints == null || currentPoints.Count == 0) return; - float size = context.StrokeWidth; - float baseScale = size / 20f; - byte flow = context.Flow; - byte opacity = context.Opacity; + // Get current shape from context + var shape = context.BrushShape; + if (shape?.Path == null) return; - using var scaledPath = new SKPath(shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); + float size = context.StrokeWidth; + float baseScale = size / 20f; + byte flow = context.Flow; + byte opacity = context.Opacity; - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true - }; + using var scaledPath = new SKPath(shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); - int index = 0; - - foreach (var item in currentPoints) - { - var point = item.Point; - var baseRotation = item.Rotation; + using var paint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true + }; - // Local random for preview jitter (Must match DrawableStamps logic) - int seed; - unchecked - { - seed = 17; - seed = seed * 23 + point.X.GetHashCode(); - seed = seed * 23 + point.Y.GetHashCode(); - } - var random = new Random(seed); + int index = 0; - // 1. Size Jitter (Consume randoms first) - float scaleFactor = 1.0f; - if (context.SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * context.SizeJitter; - scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * context.SizeJitter; - if (scaleFactor < 0.1f) scaleFactor = 0.1f; - } + foreach (var item in currentPoints) + { + var point = item.Point; + var baseRotation = item.Rotation; - // 2. Angle Jitter - float rotationDelta = 0f; - if (context.AngleJitter > 0) - { - rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * context.AngleJitter; - } + // Local random for preview jitter (Must match DrawableStamps logic) + int seed; + unchecked + { + seed = 17; + seed = seed * 23 + point.X.GetHashCode(); + seed = seed * 23 + point.Y.GetHashCode(); + } + var random = new Random(seed); - // 3. Color (Hue) Jitter - SKColor color = context.StrokeColor; - if (context.IsRainbowEnabled) - { - float hue = index * 10 % 360; - color = SKColor.FromHsl(hue, 100, 50); - } - else if (context.HueJitter > 0) - { - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * context.HueJitter * 360f; - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - paint.Color = color.WithAlpha((byte)(flow * (opacity / 255f))); + // 1. Size Jitter (Consume randoms first) + float scaleFactor = 1.0f; + if (context.SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * context.SizeJitter; + scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * context.SizeJitter; + if (scaleFactor < 0.1f) scaleFactor = 0.1f; + } - canvas.Save(); - canvas.Translate(point.X, point.Y); + // 2. Angle Jitter + float rotationDelta = 0f; + if (context.AngleJitter > 0) + { + rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * context.AngleJitter; + } - // Apply Stroke Rotation - if (Math.Abs(baseRotation) > 0.001f) - { - canvas.RotateDegrees(baseRotation); - } + // 3. Color (Hue) Jitter + SKColor color = context.StrokeColor; + if (context.IsRainbowEnabled) + { + float hue = index * 10 % 360; + color = SKColor.FromHsl(hue, 100, 50); + } + else if (context.HueJitter > 0) + { + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * context.HueJitter * 360f; + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - // Apply Jitter Rotation - if (context.AngleJitter > 0) - { - canvas.RotateDegrees(rotationDelta); - } + paint.Color = color.WithAlpha((byte)(flow * (opacity / 255f))); - // Apply Jitter Scale - if (scaleFactor != 1.0f) - { - canvas.Scale(scaleFactor); - } + canvas.Save(); + canvas.Translate(point.X, point.Y); - canvas.DrawPath(scaledPath, paint); - canvas.Restore(); + // Apply Stroke Rotation + if (Math.Abs(baseRotation) > 0.001f) + { + canvas.RotateDegrees(baseRotation); + } - index++; + // Apply Jitter Rotation + if (context.AngleJitter > 0) + { + canvas.RotateDegrees(rotationDelta); } + + // Apply Jitter Scale + if (scaleFactor != 1.0f) + { + canvas.Scale(scaleFactor); + } + + canvas.DrawPath(scaledPath, paint); + canvas.Restore(); + + index++; } } } \ No newline at end of file diff --git a/Logic/Tools/IDrawingTool.cs b/Logic/Tools/IDrawingTool.cs index 0611738..9846ceb 100644 --- a/Logic/Tools/IDrawingTool.cs +++ b/Logic/Tools/IDrawingTool.cs @@ -22,33 +22,31 @@ */ using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public enum ToolType { - public enum ToolType - { - None, - Select, - Freehand, - Rectangle, - Ellipse, - Line, - Fill, - Eraser - } + None, + Select, + Freehand, + Rectangle, + Ellipse, + Line, + Fill, + Eraser +} - public interface IDrawingTool - { - string Name { get; } - ToolType Type { get; } +public interface IDrawingTool +{ + string Name { get; } + ToolType Type { get; } - void OnTouchPressed(SKPoint point, ToolContext context); - void OnTouchMoved(SKPoint point, ToolContext context); - void OnTouchReleased(SKPoint point, ToolContext context); - void OnTouchCancelled(ToolContext context); - void DrawPreview(SKCanvas canvas, ToolContext context); - } + void OnTouchPressed(SKPoint point, ToolContext context); + void OnTouchMoved(SKPoint point, ToolContext context); + void OnTouchReleased(SKPoint point, ToolContext context); + void OnTouchCancelled(ToolContext context); + void DrawPreview(SKCanvas canvas, ToolContext context); } diff --git a/Logic/Tools/LineTool.cs b/Logic/Tools/LineTool.cs index 45ed696..06371d2 100644 --- a/Logic/Tools/LineTool.cs +++ b/Logic/Tools/LineTool.cs @@ -23,73 +23,71 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class LineTool(IMessageBus messageBus) : IDrawingTool { - public class LineTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Line"; - public ToolType Type => ToolType.Line; + public string Name => "Line"; + public ToolType Type => ToolType.Line; - private SKPoint startPoint; - private DrawableLine? currentLine; - private readonly IMessageBus messageBus = messageBus; + private SKPoint startPoint; + private DrawableLine? currentLine; + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; - - startPoint = point; - currentLine = new DrawableLine - { - StartPoint = SKPoint.Empty, - EndPoint = SKPoint.Empty, - TransformMatrix = SKMatrix.CreateTranslation(point.X, point.Y), - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity - }; - } + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - public void OnTouchMoved(SKPoint point, ToolContext context) + startPoint = point; + currentLine = new DrawableLine { - if (context.CurrentLayer?.IsLocked == true || currentLine == null) return; - - currentLine.EndPoint = point - startPoint; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + StartPoint = SKPoint.Empty, + EndPoint = SKPoint.Empty, + TransformMatrix = SKMatrix.CreateTranslation(point.X, point.Y), + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity + }; + } - public void OnTouchReleased(SKPoint point, ToolContext context) - { - if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentLine == null) return; + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true || currentLine == null) return; - if (!currentLine.EndPoint.Equals(SKPoint.Empty)) - { - context.CurrentLayer.Elements.Add(currentLine); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } + currentLine.EndPoint = point - startPoint; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - currentLine = null; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentLine == null) return; - public void OnTouchCancelled(ToolContext context) + if (!currentLine.EndPoint.Equals(SKPoint.Empty)) { - currentLine = null; - messageBus.SendMessage(new CanvasInvalidateMessage()); + context.CurrentLayer.Elements.Add(currentLine); + messageBus.SendMessage(new DrawingStateChangedMessage()); } - public void DrawPreview(SKCanvas canvas, ToolContext context) + currentLine = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void OnTouchCancelled(ToolContext context) + { + currentLine = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (currentLine != null) { - if (currentLine != null) - { - currentLine.Draw(canvas); - } + currentLine.Draw(canvas); } } } \ No newline at end of file diff --git a/Logic/Tools/RectangleTool.cs b/Logic/Tools/RectangleTool.cs index 02ee9c0..b633c94 100644 --- a/Logic/Tools/RectangleTool.cs +++ b/Logic/Tools/RectangleTool.cs @@ -25,33 +25,32 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class RectangleTool(IMessageBus messageBus) : ShapeTool(messageBus) { - public class RectangleTool(IMessageBus messageBus) : ShapeTool(messageBus) - { - public override string Name => "Rectangle"; - public override ToolType Type => ToolType.Rectangle; + public override string Name => "Rectangle"; + public override ToolType Type => ToolType.Rectangle; - protected override DrawableRectangle CreateShape(ToolContext context) + protected override DrawableRectangle CreateShape(ToolContext context) + { + return new DrawableRectangle { - return new DrawableRectangle - { - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity, - FillColor = context.FillColor - }; - } + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity, + FillColor = context.FillColor + }; + } - protected override void UpdateShape(DrawableRectangle shape, SKRect bounds, SKMatrix transform) - { - shape.TransformMatrix = transform; - shape.Rectangle = bounds; - } + protected override void UpdateShape(DrawableRectangle shape, SKRect bounds, SKMatrix transform) + { + shape.TransformMatrix = transform; + shape.Rectangle = bounds; + } - protected override bool IsShapeValid(DrawableRectangle shape) - { - return shape.Rectangle.Width > 0 || shape.Rectangle.Height > 0; - } + protected override bool IsShapeValid(DrawableRectangle shape) + { + return shape.Rectangle.Width > 0 || shape.Rectangle.Height > 0; } } \ No newline at end of file diff --git a/Logic/Tools/SelectTool.cs b/Logic/Tools/SelectTool.cs index 1517bd8..8e496ce 100644 --- a/Logic/Tools/SelectTool.cs +++ b/Logic/Tools/SelectTool.cs @@ -23,272 +23,270 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools -{ - public enum SelectionState { None, Selecting, Dragging, Resizing } - public enum ResizeHandle { None, TopLeft, TopRight, BottomLeft, BottomRight, Top, Right, Bottom, Left } +namespace LunaDraw.Logic.Tools; + +public enum SelectionState { None, Selecting, Dragging, Resizing } +public enum ResizeHandle { None, TopLeft, TopRight, BottomLeft, BottomRight, Top, Right, Bottom, Left } - public class SelectTool(IMessageBus messageBus) : IDrawingTool +public class SelectTool(IMessageBus messageBus) : IDrawingTool +{ + public string Name => "Select"; + public ToolType Type => ToolType.Select; + + private SKPoint lastPoint; + private SelectionState currentState = SelectionState.None; + private ResizeHandle activeHandle = ResizeHandle.None; + private SKRect originalBounds; + private Dictionary originalTransforms = []; + private SKPoint resizeStartPoint; + private readonly IMessageBus messageBus = messageBus; + + public void OnTouchPressed(SKPoint point, ToolContext context) { - public string Name => "Select"; - public ToolType Type => ToolType.Select; - - private SKPoint lastPoint; - private SelectionState currentState = SelectionState.None; - private ResizeHandle activeHandle = ResizeHandle.None; - private SKRect originalBounds; - private Dictionary originalTransforms = []; - private SKPoint resizeStartPoint; - private readonly IMessageBus messageBus = messageBus; - - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + if (context.CurrentLayer?.IsLocked == true) return; - lastPoint = point; + lastPoint = point; - // Check for resize handles if we have a selection - if (context.SelectionObserver.Selected.Any()) - { - var bounds = context.SelectionObserver.GetBounds(); - var handle = GetResizeHandle(point, bounds, context.Scale); + // Check for resize handles if we have a selection + if (context.SelectionObserver.Selected.Any()) + { + var bounds = context.SelectionObserver.GetBounds(); + var handle = GetResizeHandle(point, bounds, context.Scale); - if (handle != ResizeHandle.None) - { - currentState = SelectionState.Resizing; - activeHandle = handle; - resizeStartPoint = point; - originalBounds = bounds; - originalTransforms = context.SelectionObserver.GetAll() - .ToDictionary(e => e, e => e.TransformMatrix); - - messageBus.SendMessage(new CanvasInvalidateMessage()); - return; - } + if (handle != ResizeHandle.None) + { + currentState = SelectionState.Resizing; + activeHandle = handle; + resizeStartPoint = point; + originalBounds = bounds; + originalTransforms = context.SelectionObserver.GetAll() + .ToDictionary(e => e, e => e.TransformMatrix); + + messageBus.SendMessage(new CanvasInvalidateMessage()); + return; } + } - IDrawableElement? hitElement = null; + IDrawableElement? hitElement = null; - if (context.Layers != null) - { - // Iterate layers from Top (Last) to Bottom (First) - foreach (var layer in context.Layers.Reverse()) - { - if (!layer.IsVisible) continue; - - // Hit test elements in this layer, sorted by ZIndex Descending (Topmost first) - var hit = layer.Elements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); - - if (hit != null) - { - hitElement = hit; - break; // Found the top-most element - } - } - } - else + if (context.Layers != null) + { + // Iterate layers from Top (Last) to Bottom (First) + foreach (var layer in context.Layers.Reverse()) { - // Fallback to old behavior if Layers not provided (shouldn't happen with updated context) - hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); - } + if (!layer.IsVisible) continue; - if (hitElement != null) - { - if (!context.SelectionObserver.Contains(hitElement)) + // Hit test elements in this layer, sorted by ZIndex Descending (Topmost first) + var hit = layer.Elements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); + + if (hit != null) { - context.SelectionObserver.Clear(); - context.SelectionObserver.Add(hitElement); + hitElement = hit; + break; // Found the top-most element } - currentState = SelectionState.Dragging; - } - else - { - context.SelectionObserver.Clear(); - currentState = SelectionState.None; } - - messageBus.SendMessage(new CanvasInvalidateMessage()); } - - public void OnTouchMoved(SKPoint point, ToolContext context) + else { - if (context.CurrentLayer?.IsLocked == true) return; - - switch (currentState) - { - case SelectionState.Dragging: - var delta = point - lastPoint; - foreach (var element in context.SelectionObserver.GetAll()) - { - element.Translate(delta); - } - lastPoint = point; - messageBus.SendMessage(new CanvasInvalidateMessage()); - break; - - case SelectionState.Resizing: - PerformResize(point, context); - messageBus.SendMessage(new CanvasInvalidateMessage()); - break; - } + // Fallback to old behavior if Layers not provided (shouldn't happen with updated context) + hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); } - public void OnTouchReleased(SKPoint point, ToolContext context) + if (hitElement != null) { - if (currentState == SelectionState.Dragging || currentState == SelectionState.Resizing) + if (!context.SelectionObserver.Contains(hitElement)) { - messageBus.SendMessage(new DrawingStateChangedMessage()); + context.SelectionObserver.Clear(); + context.SelectionObserver.Add(hitElement); } - - currentState = SelectionState.None; - activeHandle = ResizeHandle.None; - originalTransforms?.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); + currentState = SelectionState.Dragging; } - - public void OnTouchCancelled(ToolContext context) + else { + context.SelectionObserver.Clear(); currentState = SelectionState.None; - activeHandle = ResizeHandle.None; - originalTransforms?.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (context.SelectionObserver.Selected.Any()) - { - var bounds = context.SelectionObserver.GetBounds(); - if (bounds.IsEmpty) return; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - // Draw selection rectangle - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue, - StrokeWidth = 1, - PathEffect = SKPathEffect.CreateDash(new[] { 4f, 4f }, 0) - }; - canvas.DrawRect(bounds, paint); - - // Draw resize handles - if (currentState != SelectionState.Resizing) + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; + + switch (currentState) + { + case SelectionState.Dragging: + var delta = point - lastPoint; + foreach (var element in context.SelectionObserver.GetAll()) { - float scale = context.Scale; - // Note: context.Scale is just TotalMatrix.ScaleX in MainViewModel logic. - // But here we need inverse scale for drawing constant size handles? - // GetResizeHandle used (1/ScaleX). - // If context.Scale is ScaleX, then handleDrawScale = 1.0f / context.Scale. - - float handleDrawScale = 1.0f / (Math.Abs(context.Scale) < 0.0001f ? 1.0f : context.Scale); - - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.MidY), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.MidY), handleDrawScale); + element.Translate(delta); } - } + lastPoint = point; + messageBus.SendMessage(new CanvasInvalidateMessage()); + break; + + case SelectionState.Resizing: + PerformResize(point, context); + messageBus.SendMessage(new CanvasInvalidateMessage()); + break; } + } - private ResizeHandle GetResizeHandle(SKPoint point, SKRect bounds, float scale) + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (currentState == SelectionState.Dragging || currentState == SelectionState.Resizing) { - const float baseHandleSize = 24f; // Size in screen pixels at 1:1 scale - float scaledHandleSize = baseHandleSize / scale; // Adjust based on current zoom level - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Top), scaledHandleSize)) return ResizeHandle.TopLeft; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Top), scaledHandleSize)) return ResizeHandle.TopRight; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomLeft; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomRight; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Top), scaledHandleSize)) return ResizeHandle.Top; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.MidY), scaledHandleSize)) return ResizeHandle.Right; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Bottom), scaledHandleSize)) return ResizeHandle.Bottom; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.MidY), scaledHandleSize)) return ResizeHandle.Left; - return ResizeHandle.None; + messageBus.SendMessage(new DrawingStateChangedMessage()); } - private static bool IsPointNear(SKPoint p1, SKPoint p2, float tolerance) + currentState = SelectionState.None; + activeHandle = ResizeHandle.None; + originalTransforms?.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void OnTouchCancelled(ToolContext context) + { + currentState = SelectionState.None; + activeHandle = ResizeHandle.None; + originalTransforms?.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (context.SelectionObserver.Selected.Any()) { - return (p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y) < tolerance * tolerance; + var bounds = context.SelectionObserver.GetBounds(); + if (bounds.IsEmpty) return; + + // Draw selection rectangle + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue, + StrokeWidth = 1, + PathEffect = SKPathEffect.CreateDash(new[] { 4f, 4f }, 0) + }; + canvas.DrawRect(bounds, paint); + + // Draw resize handles + if (currentState != SelectionState.Resizing) + { + float scale = context.Scale; + // Note: context.Scale is just TotalMatrix.ScaleX in MainViewModel logic. + // But here we need inverse scale for drawing constant size handles? + // GetResizeHandle used (1/ScaleX). + // If context.Scale is ScaleX, then handleDrawScale = 1.0f / context.Scale. + + float handleDrawScale = 1.0f / (Math.Abs(context.Scale) < 0.0001f ? 1.0f : context.Scale); + + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.MidY), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.MidY), handleDrawScale); + } } + } - private void PerformResize(SKPoint currentPoint, ToolContext context) - { - if (originalTransforms == null) return; + private ResizeHandle GetResizeHandle(SKPoint point, SKRect bounds, float scale) + { + const float baseHandleSize = 24f; // Size in screen pixels at 1:1 scale + float scaledHandleSize = baseHandleSize / scale; // Adjust based on current zoom level + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Top), scaledHandleSize)) return ResizeHandle.TopLeft; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Top), scaledHandleSize)) return ResizeHandle.TopRight; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomLeft; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomRight; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Top), scaledHandleSize)) return ResizeHandle.Top; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.MidY), scaledHandleSize)) return ResizeHandle.Right; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Bottom), scaledHandleSize)) return ResizeHandle.Bottom; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.MidY), scaledHandleSize)) return ResizeHandle.Left; + return ResizeHandle.None; + } - var dragDelta = currentPoint - resizeStartPoint; - var newBounds = SelectTool.CalculateNewBounds(originalBounds, activeHandle, dragDelta); + private static bool IsPointNear(SKPoint p1, SKPoint p2, float tolerance) + { + return (p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y) < tolerance * tolerance; + } - if (newBounds.Width < 5 || newBounds.Height < 5) return; + private void PerformResize(SKPoint currentPoint, ToolContext context) + { + if (originalTransforms == null) return; - var sx = originalBounds.Width == 0 ? 1 : newBounds.Width / originalBounds.Width; - var sy = originalBounds.Height == 0 ? 1 : newBounds.Height / originalBounds.Height; - var tx = newBounds.Left - (originalBounds.Left * sx); - var ty = newBounds.Top - (originalBounds.Top * sy); + var dragDelta = currentPoint - resizeStartPoint; + var newBounds = SelectTool.CalculateNewBounds(originalBounds, activeHandle, dragDelta); - var transformFromOriginal = new SKMatrix(sx, 0, tx, 0, sy, ty, 0, 0, 1); + if (newBounds.Width < 5 || newBounds.Height < 5) return; - foreach (var element in context.SelectionObserver.GetAll()) - { - if (originalTransforms.TryGetValue(element, out var originalMatrix)) - { - // Apply the resize transformation (calculated in world space) AFTER the original matrix - // New = Resize * Original - element.TransformMatrix = SKMatrix.Concat(transformFromOriginal, originalMatrix); - } - } - } + var sx = originalBounds.Width == 0 ? 1 : newBounds.Width / originalBounds.Width; + var sy = originalBounds.Height == 0 ? 1 : newBounds.Height / originalBounds.Height; + var tx = newBounds.Left - (originalBounds.Left * sx); + var ty = newBounds.Top - (originalBounds.Top * sy); - private static SKRect CalculateNewBounds(SKRect bounds, ResizeHandle handle, SKPoint dragDelta) - { - float left = bounds.Left, top = bounds.Top, right = bounds.Right, bottom = bounds.Bottom; + var transformFromOriginal = new SKMatrix(sx, 0, tx, 0, sy, ty, 0, 0, 1); - switch (handle) + foreach (var element in context.SelectionObserver.GetAll()) + { + if (originalTransforms.TryGetValue(element, out var originalMatrix)) { - case ResizeHandle.TopLeft: left += dragDelta.X; top += dragDelta.Y; break; - case ResizeHandle.Top: top += dragDelta.Y; break; - case ResizeHandle.TopRight: right += dragDelta.X; top += dragDelta.Y; break; - case ResizeHandle.Left: left += dragDelta.X; break; - case ResizeHandle.Right: right += dragDelta.X; break; - case ResizeHandle.BottomLeft: left += dragDelta.X; bottom += dragDelta.Y; break; - case ResizeHandle.Bottom: bottom += dragDelta.Y; break; - case ResizeHandle.BottomRight: right += dragDelta.X; bottom += dragDelta.Y; break; + // Apply the resize transformation (calculated in world space) AFTER the original matrix + // New = Resize * Original + element.TransformMatrix = SKMatrix.Concat(transformFromOriginal, originalMatrix); } + } + } - if (left > right) { var temp = left; left = right; right = temp; } - if (top > bottom) { var temp = top; top = bottom; bottom = temp; } + private static SKRect CalculateNewBounds(SKRect bounds, ResizeHandle handle, SKPoint dragDelta) + { + float left = bounds.Left, top = bounds.Top, right = bounds.Right, bottom = bounds.Bottom; - return new SKRect(left, top, right, bottom); + switch (handle) + { + case ResizeHandle.TopLeft: left += dragDelta.X; top += dragDelta.Y; break; + case ResizeHandle.Top: top += dragDelta.Y; break; + case ResizeHandle.TopRight: right += dragDelta.X; top += dragDelta.Y; break; + case ResizeHandle.Left: left += dragDelta.X; break; + case ResizeHandle.Right: right += dragDelta.X; break; + case ResizeHandle.BottomLeft: left += dragDelta.X; bottom += dragDelta.Y; break; + case ResizeHandle.Bottom: bottom += dragDelta.Y; break; + case ResizeHandle.BottomRight: right += dragDelta.X; bottom += dragDelta.Y; break; } - private static void DrawResizeHandle(SKCanvas canvas, SKPoint point, float scale) + if (left > right) { var temp = left; left = right; right = temp; } + if (top > bottom) { var temp = top; top = bottom; bottom = temp; } + + return new SKRect(left, top, right, bottom); + } + + private static void DrawResizeHandle(SKCanvas canvas, SKPoint point, float scale) + { + const float baseHandleSize = 4f; + float handleSize = baseHandleSize * scale; + using var paint = new SKPaint { - const float baseHandleSize = 4f; - float handleSize = baseHandleSize * scale; - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = SKColors.White - }; - canvas.DrawCircle(point, handleSize, paint); - paint.Style = SKPaintStyle.Stroke; - paint.Color = SKColors.DodgerBlue; - paint.StrokeWidth = 1; - canvas.DrawCircle(point, handleSize, paint); - } + Style = SKPaintStyle.Fill, + Color = SKColors.White + }; + canvas.DrawCircle(point, handleSize, paint); + paint.Style = SKPaintStyle.Stroke; + paint.Color = SKColors.DodgerBlue; + paint.StrokeWidth = 1; + canvas.DrawCircle(point, handleSize, paint); } } \ No newline at end of file diff --git a/Logic/Tools/ShapeTool.cs b/Logic/Tools/ShapeTool.cs index dc6a927..5762133 100644 --- a/Logic/Tools/ShapeTool.cs +++ b/Logic/Tools/ShapeTool.cs @@ -24,69 +24,67 @@ using LunaDraw.Logic.Extensions; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public abstract class ShapeTool(IMessageBus messageBus) : IDrawingTool where T : class, IDrawableElement { - public abstract class ShapeTool(IMessageBus messageBus) : IDrawingTool where T : class, IDrawableElement - { - public abstract string Name { get; } - public abstract ToolType Type { get; } + public abstract string Name { get; } + public abstract ToolType Type { get; } - protected readonly IMessageBus MessageBus = messageBus; - protected SKPoint StartPoint; - protected T? CurrentShape; + protected readonly IMessageBus MessageBus = messageBus; + protected SKPoint StartPoint; + protected T? CurrentShape; - protected abstract T CreateShape(ToolContext context); - protected abstract void UpdateShape(T shape, SKRect bounds, SKMatrix transform); - protected abstract bool IsShapeValid(T shape); + protected abstract T CreateShape(ToolContext context); + protected abstract void UpdateShape(T shape, SKRect bounds, SKMatrix transform); + protected abstract bool IsShapeValid(T shape); - public virtual void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public virtual void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - StartPoint = point; - CurrentShape = CreateShape(context); - } + StartPoint = point; + CurrentShape = CreateShape(context); + } - public virtual void OnTouchMoved(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true || CurrentShape == null) return; + public virtual void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true || CurrentShape == null) return; - var (transform, bounds) = context.CanvasMatrix.CalculateRotatedBounds(StartPoint, point); - UpdateShape(CurrentShape, bounds, transform); + var (transform, bounds) = context.CanvasMatrix.CalculateRotatedBounds(StartPoint, point); + UpdateShape(CurrentShape, bounds, transform); - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void OnTouchReleased(SKPoint point, ToolContext context) - { - if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || CurrentShape == null) return; + public virtual void OnTouchReleased(SKPoint point, ToolContext context) + { + if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || CurrentShape == null) return; - if (IsShapeValid(CurrentShape)) - { - context.CurrentLayer.Elements.Add(CurrentShape); - MessageBus.SendMessage(new DrawingStateChangedMessage()); - } + if (IsShapeValid(CurrentShape)) + { + context.CurrentLayer.Elements.Add(CurrentShape); + MessageBus.SendMessage(new DrawingStateChangedMessage()); + } - CurrentShape = null; - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + CurrentShape = null; + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void OnTouchCancelled(ToolContext context) - { - CurrentShape = null; - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + public virtual void OnTouchCancelled(ToolContext context) + { + CurrentShape = null; + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (CurrentShape != null) - { - CurrentShape.Draw(canvas); - } - } + public virtual void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (CurrentShape != null) + { + CurrentShape.Draw(canvas); } + } } diff --git a/Logic/Utils/BitmapCache.cs b/Logic/Utils/BitmapCache.cs index 360a0e4..5eef2db 100644 --- a/Logic/Utils/BitmapCache.cs +++ b/Logic/Utils/BitmapCache.cs @@ -23,150 +23,83 @@ using SkiaSharp; using System.Collections.Concurrent; +using LunaDraw.Logic.Extensions; -namespace LunaDraw.Logic.Managers -{ - public interface IBitmapCache : IDisposable - { - SKBitmap GetBitmap(string path, int targetWidth, int targetHeight); - Task GetBitmapAsync(string path, int targetWidth, int targetHeight); - void ClearCache(); - } - - public class BitmapCache : IBitmapCache - { - private readonly ConcurrentDictionary> cache = new(); - - public SKBitmap GetBitmap(string path, int targetWidth, int targetHeight) - { - var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - - if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) - { - return bitmap; - } - - var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); - if (newBitmap != null) - { - cache.AddOrUpdate(key, - new WeakReference(newBitmap), - (_, __) => new WeakReference(newBitmap)); - } - - return newBitmap ?? new SKBitmap(); - } - - public async Task GetBitmapAsync(string path, int targetWidth, int targetHeight) - { - var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - - if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) - { - return bitmap; - } - - return await Task.Run(() => - { - var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); - if (newBitmap != null) - { - cache.AddOrUpdate(key, - new WeakReference(newBitmap), - (_, __) => new WeakReference(newBitmap)); - } - - return newBitmap ?? new SKBitmap(); - }); - } - - private static string GenerateKey(string path, int width, int height) - { - return $"{path}_{width}x{height}"; - } - - private static SKBitmap LoadDownsampledBitmap(string path, int targetWidth, int targetHeight) - { - try - { - if (!File.Exists(path)) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] File not found: {path}"); - - return new SKBitmap(); - } +namespace LunaDraw.Logic.Utils; - using var stream = File.OpenRead(path); - using var codec = SKCodec.Create(stream); - - if (codec == null) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] Failed to create codec for: {path}"); - - return new SKBitmap(); - } - - var info = codec.Info; - - // Calculate scale - float scale = 1.0f; - if (targetWidth > 0 && targetHeight > 0) - { - float scaleX = (float)targetWidth / info.Width; - float scaleY = (float)targetHeight / info.Height; - scale = Math.Min(scaleX, scaleY); - } +public interface IBitmapCache : IDisposable +{ + SKBitmap GetBitmap(string path, int targetWidth, int targetHeight); + Task GetBitmapAsync(string path, int targetWidth, int targetHeight); + void ClearCache(); +} - if (scale >= 1.0f || (targetWidth == 0 && targetHeight == 0)) - { - return SKBitmap.Decode(codec); - } +public class BitmapCache : IBitmapCache +{ + private readonly ConcurrentDictionary> cache = new(); - // Get supported dimensions for this scale - var supportedInfo = codec.GetScaledDimensions(scale); + public SKBitmap GetBitmap(string path, int targetWidth, int targetHeight) + { + var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - // Use the supported dimensions for decoding - var decodeInfo = new SKImageInfo(supportedInfo.Width, supportedInfo.Height, info.ColorType, info.AlphaType); + if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) + { + return bitmap; + } - var bitmap = new SKBitmap(decodeInfo); - var result = codec.GetPixels(decodeInfo, bitmap.GetPixels()); + var newBitmap = SkiaSharpExtensions.LoadBitmapDownsampled(path, targetWidth, targetHeight); + if (newBitmap != null) + { + cache.AddOrUpdate(key, + new WeakReference(newBitmap), + (_, __) => new WeakReference(newBitmap)); + } - if (result == SKCodecResult.Success || result == SKCodecResult.IncompleteInput) - { - return bitmap; - } - else - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] GetPixels failed: {result}"); - bitmap.Dispose(); - // Fallback: try full decode if downsample fails? - // Or maybe the scale was just invalid. - return new SKBitmap(); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] Exception loading bitmap: {ex}"); + return newBitmap ?? new SKBitmap(); + } - return new SKBitmap(); - } - } + public async Task GetBitmapAsync(string path, int targetWidth, int targetHeight) + { + var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - public void ClearCache() - { - foreach (var weakRef in cache.Values) - { - if (weakRef.TryGetTarget(out var bitmap)) - { - bitmap?.Dispose(); - } - } - cache.Clear(); - } + if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) + { + return bitmap; + } - public void Dispose() - { - ClearCache(); - } + return await Task.Run(() => + { + var newBitmap = SkiaSharpExtensions.LoadBitmapDownsampled(path, targetWidth, targetHeight); + if (newBitmap != null) + { + cache.AddOrUpdate(key, + new WeakReference(newBitmap), + (_, __) => new WeakReference(newBitmap)); + } + + return newBitmap ?? new SKBitmap(); + }); + } + + private static string GenerateKey(string path, int width, int height) + { + return $"{path}_{width}x{height}"; + } + + public void ClearCache() + { + foreach (var weakRef in cache.Values) + { + if (weakRef.TryGetTarget(out var bitmap)) + { + bitmap?.Dispose(); + } } + cache.Clear(); + } + + public void Dispose() + { + ClearCache(); + } } diff --git a/Logic/Utils/ClipboardMemento.cs b/Logic/Utils/ClipboardMemento.cs index ce231f4..e18e1c7 100644 --- a/Logic/Utils/ClipboardMemento.cs +++ b/Logic/Utils/ClipboardMemento.cs @@ -24,23 +24,22 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class ClipboardMemento : ReactiveObject { - public class ClipboardMemento : ReactiveObject - { - private List clipboard = new(); + private List clipboard = new(); - public void Copy(IEnumerable elements) - { - clipboard = elements.Select(e => e.Clone()).ToList(); - this.RaisePropertyChanged(nameof(HasItems)); - } + public void Copy(IEnumerable elements) + { + clipboard = elements.Select(e => e.Clone()).ToList(); + this.RaisePropertyChanged(nameof(HasItems)); + } - public IEnumerable Paste() - { - return clipboard.Select(e => e.Clone()); - } + public IEnumerable Paste() + { + return clipboard.Select(e => e.Clone()); + } - public bool HasItems => clipboard.Count > 0; - } + public bool HasItems => clipboard.Count > 0; } diff --git a/Logic/Utils/HistoryMemento.cs b/Logic/Utils/HistoryMemento.cs index c47e2dc..812ffed 100644 --- a/Logic/Utils/HistoryMemento.cs +++ b/Logic/Utils/HistoryMemento.cs @@ -24,64 +24,63 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class HistoryMemento : ReactiveObject { - public class HistoryMemento : ReactiveObject - { - private readonly List> history = []; - private int historyIndex = -1; + private readonly List> history = []; + private int historyIndex = -1; - public bool CanUndo => historyIndex > 0; - public bool CanRedo => historyIndex < history.Count - 1; + public bool CanUndo => historyIndex > 0; + public bool CanRedo => historyIndex < history.Count - 1; - public void SaveState(IEnumerable layers) + public void SaveState(IEnumerable layers) + { + // If we have undone, and then make a new action, we clear the 'redo' history + if (historyIndex < history.Count - 1) { - // If we have undone, and then make a new action, we clear the 'redo' history - if (historyIndex < history.Count - 1) - { - history.RemoveRange(historyIndex + 1, history.Count - (historyIndex + 1)); - } + history.RemoveRange(historyIndex + 1, history.Count - (historyIndex + 1)); + } - // Deep copy the layers - var stateSnapshot = layers.Select(l => l.Clone()).ToList(); - history.Add(stateSnapshot); - historyIndex++; + // Deep copy the layers + var stateSnapshot = layers.Select(l => l.Clone()).ToList(); + history.Add(stateSnapshot); + historyIndex++; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); - } + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); + } - public List? Undo() - { - if (!CanUndo) return null; - historyIndex--; + public List? Undo() + { + if (!CanUndo) return null; + historyIndex--; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); - // Return a deep copy of the state to ensure history integrity - return history[historyIndex].Select(l => l.Clone()).ToList(); - } + // Return a deep copy of the state to ensure history integrity + return history[historyIndex].Select(l => l.Clone()).ToList(); + } - public List? Redo() - { - if (!CanRedo) return null; - historyIndex++; + public List? Redo() + { + if (!CanRedo) return null; + historyIndex++; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); - // Return a deep copy of the state - return history[historyIndex].Select(l => l.Clone()).ToList(); - } + // Return a deep copy of the state + return history[historyIndex].Select(l => l.Clone()).ToList(); + } - public void Clear() - { - history.Clear(); - historyIndex = -1; + public void Clear() + { + history.Clear(); + historyIndex = -1; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); - } + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); } } diff --git a/Logic/Utils/ILayerFacade.cs b/Logic/Utils/ILayerFacade.cs index fad5e07..e24937f 100644 --- a/Logic/Utils/ILayerFacade.cs +++ b/Logic/Utils/ILayerFacade.cs @@ -24,19 +24,18 @@ using System.Collections.ObjectModel; using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public interface ILayerFacade { - public interface ILayerFacade - { - ObservableCollection Layers { get; } - Layer? CurrentLayer { get; set; } - HistoryMemento HistoryMemento { get; } - void AddLayer(); - void RemoveLayer(Layer layer); - void MoveLayerForward(Layer layer); - void MoveLayerBackward(Layer layer); - void MoveLayer(int oldIndex, int newIndex); - void MoveElementsToLayer(IEnumerable elements, Layer targetLayer); - void SaveState(); - } + ObservableCollection Layers { get; } + Layer? CurrentLayer { get; set; } + HistoryMemento HistoryMemento { get; } + void AddLayer(); + void RemoveLayer(Layer layer); + void MoveLayerForward(Layer layer); + void MoveLayerBackward(Layer layer); + void MoveLayer(int oldIndex, int newIndex); + void MoveElementsToLayer(IEnumerable elements, Layer targetLayer); + void SaveState(); } diff --git a/Logic/Utils/IPreferencesFacade.cs b/Logic/Utils/IPreferencesFacade.cs new file mode 100644 index 0000000..21b4b4f --- /dev/null +++ b/Logic/Utils/IPreferencesFacade.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +namespace LunaDraw.Logic.Utils; + +public interface IPreferencesFacade +{ + + string Get(AppPreference key); + + T Get(AppPreference key); + + void Set(AppPreference key, bool value); + + void Set(AppPreference key, T? value) => Preferences.Set(key.ToString(), value?.ToString()); +} diff --git a/Logic/Utils/LayerFacade.cs b/Logic/Utils/LayerFacade.cs index 0109d24..5a29337 100644 --- a/Logic/Utils/LayerFacade.cs +++ b/Logic/Utils/LayerFacade.cs @@ -26,120 +26,119 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class LayerFacade : ReactiveObject, ILayerFacade { - public class LayerFacade : ReactiveObject, ILayerFacade + public ObservableCollection Layers { get; } = []; + public HistoryMemento HistoryMemento { get; } = new HistoryMemento(); + + private Layer? currentLayer; + public Layer? CurrentLayer { - public ObservableCollection Layers { get; } = []; - public HistoryMemento HistoryMemento { get; } = new HistoryMemento(); + get => currentLayer; + set => this.RaiseAndSetIfChanged(ref currentLayer, value); + } - private Layer? currentLayer; - public Layer? CurrentLayer - { - get => currentLayer; - set => this.RaiseAndSetIfChanged(ref currentLayer, value); - } + private readonly IMessageBus messageBus; - private readonly IMessageBus messageBus; + public LayerFacade(IMessageBus messageBus) + { + this.messageBus = messageBus; + // Initialize with a default layer + var initialLayer = new Layer { Name = "Layer 1" }; + Layers.Add(initialLayer); + CurrentLayer = initialLayer; - public LayerFacade(IMessageBus messageBus) - { - this.messageBus = messageBus; - // Initialize with a default layer - var initialLayer = new Layer { Name = "Layer 1" }; - Layers.Add(initialLayer); - CurrentLayer = initialLayer; + this.messageBus.Listen().Subscribe(_ => SaveState()); - this.messageBus.Listen().Subscribe(_ => SaveState()); + SaveState(); + } - SaveState(); - } + public void AddLayer() + { + var newLayer = new Layer { Name = $"Layer {Layers.Count + 1}" }; + Layers.Add(newLayer); + CurrentLayer = newLayer; + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void AddLayer() + public void RemoveLayer(Layer layer) + { + if (Layers.Count > 1) { - var newLayer = new Layer { Name = $"Layer {Layers.Count + 1}" }; - Layers.Add(newLayer); - CurrentLayer = newLayer; + // Select a different layer before removing the current one to avoid UI selection issues + var nextLayer = Layers.FirstOrDefault(l => l != layer); + if (nextLayer != null) + { + CurrentLayer = nextLayer; + } + + Layers.Remove(layer); SaveState(); messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void RemoveLayer(Layer layer) + public void MoveLayerForward(Layer layer) + { + int index = Layers.IndexOf(layer); + if (index >= 0 && index < Layers.Count - 1) { - if (Layers.Count > 1) - { - // Select a different layer before removing the current one to avoid UI selection issues - var nextLayer = Layers.FirstOrDefault(l => l != layer); - if (nextLayer != null) - { - CurrentLayer = nextLayer; - } - - Layers.Remove(layer); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(index, index + 1); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayerForward(Layer layer) + public void MoveLayerBackward(Layer layer) + { + int index = Layers.IndexOf(layer); + if (index > 0) { - int index = Layers.IndexOf(layer); - if (index >= 0 && index < Layers.Count - 1) - { - Layers.Move(index, index + 1); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(index, index - 1); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayerBackward(Layer layer) + public void MoveLayer(int oldIndex, int newIndex) + { + if (oldIndex >= 0 && oldIndex < Layers.Count && newIndex >= 0 && newIndex < Layers.Count) { - int index = Layers.IndexOf(layer); - if (index > 0) - { - Layers.Move(index, index - 1); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(oldIndex, newIndex); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayer(int oldIndex, int newIndex) - { - if (oldIndex >= 0 && oldIndex < Layers.Count && newIndex >= 0 && newIndex < Layers.Count) - { - Layers.Move(oldIndex, newIndex); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - } + public void MoveElementsToLayer(IEnumerable elements, Layer targetLayer) + { + if (!Layers.Contains(targetLayer)) return; - public void MoveElementsToLayer(IEnumerable elements, Layer targetLayer) + bool changed = false; + foreach (var element in elements.ToList()) // ToList to avoid modification during enumeration { - if (!Layers.Contains(targetLayer)) return; - - bool changed = false; - foreach (var element in elements.ToList()) // ToList to avoid modification during enumeration - { - // Find the layer containing this element - var sourceLayer = Layers.FirstOrDefault(l => l.Elements.Contains(element)); - if (sourceLayer != null && sourceLayer != targetLayer) - { - sourceLayer.Elements.Remove(element); - targetLayer.Elements.Add(element); - changed = true; - } - } - - if (changed) + // Find the layer containing this element + var sourceLayer = Layers.FirstOrDefault(l => l.Elements.Contains(element)); + if (sourceLayer != null && sourceLayer != targetLayer) { - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); + sourceLayer.Elements.Remove(element); + targetLayer.Elements.Add(element); + changed = true; } } - public void SaveState() + if (changed) { - HistoryMemento.SaveState(Layers); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } } + + public void SaveState() + { + HistoryMemento.SaveState(Layers); + } } \ No newline at end of file diff --git a/Logic/Utils/PreferencesFacade.cs b/Logic/Utils/PreferencesFacade.cs new file mode 100644 index 0000000..7a3f20f --- /dev/null +++ b/Logic/Utils/PreferencesFacade.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +namespace LunaDraw.Logic.Utils; + +public enum AppPreference +{ + AppTheme, + ShowButtonLabels, + ShowLayersPanel, + IsTransparentBackgroundEnabled +} + +public class AppPreferenceDefault +{ + public dynamic this[AppPreference appPreference] + { + get + { + return appPreference switch + { + AppPreference.AppTheme => "Automatic", + AppPreference.ShowButtonLabels => false, + AppPreference.ShowLayersPanel => false, + AppPreference.IsTransparentBackgroundEnabled => false, + _ => "" + }; + } + } +} + +public class PreferencesFacade : IPreferencesFacade +{ + public static AppPreferenceDefault Defaults => new(); + + public string Get(AppPreference key) => Preferences.Get(key.ToString(), Defaults[key]); + + public T Get(AppPreference key) => Preferences.Get(key.ToString(), Defaults[key]); + + public void Set(AppPreference key, bool value) => Preferences.Set(key.ToString(), value); + + public void Set(AppPreference key, T? value) => Preferences.Set(key.ToString(), value?.ToString()); +} diff --git a/Logic/Utils/QuadTree.cs b/Logic/Utils/QuadTree.cs deleted file mode 100644 index 8acb2e5..0000000 --- a/Logic/Utils/QuadTree.cs +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (c) 2025 CodeSoupCafe LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -using SkiaSharp; - -namespace LunaDraw.Logic.Utils -{ - public class QuadTree(int level, SKRect bounds, Func getBounds) where T : class - { - private readonly int maxObjects = 10; - private readonly int maxLevels = 5; - - private readonly int level = level; - private readonly List objects = []; - private readonly SKRect bounds = bounds; - private readonly Func getBounds = getBounds; - private QuadTree[]? nodes; - - public void Clear() - { - objects.Clear(); - - if (nodes != null) - { - foreach (var node in nodes) - { - node.Clear(); - } - nodes = null; - } - } - - private void Split() - { - float subWidth = bounds.Width / 2f; - float subHeight = bounds.Height / 2f; - float x = bounds.Left; - float y = bounds.Top; - - nodes = new QuadTree[4]; - nodes[0] = new QuadTree(level + 1, new SKRect(x + subWidth, y, x + subWidth + subWidth, y + subHeight), getBounds); - nodes[1] = new QuadTree(level + 1, new SKRect(x, y, x + subWidth, y + subHeight), getBounds); - nodes[2] = new QuadTree(level + 1, new SKRect(x, y + subHeight, x + subWidth, y + subHeight + subHeight), getBounds); - nodes[3] = new QuadTree(level + 1, new SKRect(x + subWidth, y + subHeight, x + subWidth + subWidth, y + subHeight + subHeight), getBounds); - } - - /* - * Index of the quadrant the object belongs to - */ - private int GetIndex(SKRect pRect) - { - int index = -1; - double verticalMidpoint = bounds.Left + (bounds.Width / 2f); - double horizontalMidpoint = bounds.Top + (bounds.Height / 2f); - - bool topQuadrant = pRect.Top < horizontalMidpoint && pRect.Bottom < horizontalMidpoint; - bool bottomQuadrant = pRect.Top > horizontalMidpoint; - - if (pRect.Left < verticalMidpoint && pRect.Right < verticalMidpoint) - { - if (topQuadrant) - { - index = 1; - } - else if (bottomQuadrant) - { - index = 2; - } - } - else if (pRect.Left > verticalMidpoint) - { - if (topQuadrant) - { - index = 0; - } - else if (bottomQuadrant) - { - index = 3; - } - } - - return index; - } - - public void Insert(T pObject) - { - if (nodes != null) - { - int index = GetIndex(getBounds(pObject)); - - if (index != -1) - { - nodes[index].Insert(pObject); - return; - } - } - - objects.Add(pObject); - - if (objects.Count > maxObjects && level < maxLevels) - { - if (nodes == null) - { - Split(); - } - - int i = 0; - while (i < objects.Count) - { - int index = GetIndex(getBounds(objects[i])); - if (index != -1) - { - nodes![index].Insert(objects[i]); - objects.RemoveAt(i); - } - else - { - i++; - } - } - } - } - - public List Retrieve(List returnObjects, SKRect pRect) - { - int index = GetIndex(pRect); - if (index != -1 && nodes != null) - { - nodes[index].Retrieve(returnObjects, pRect); - } - else if (nodes != null) - { - // If the rect doesn't fit into a specific quadrant (overlaps multiple), - // we must query all quadrants that it touches. - // Simplified: query all subnodes if we can't determine a single one. - // Or strictly check intersection. - // For now, naive approach: if it doesn't fit one, retrieve from all. - foreach (var node in nodes) - { - // Optimization: check intersection with node bounds - if (node.bounds.IntersectsWith(pRect)) - { - node.Retrieve(returnObjects, pRect); - } - } - } - - returnObjects.AddRange(objects); - - return returnObjects; - } - } -} \ No newline at end of file diff --git a/Logic/Utils/QuadTreeMemento.cs b/Logic/Utils/QuadTreeMemento.cs new file mode 100644 index 0000000..3f23d4a --- /dev/null +++ b/Logic/Utils/QuadTreeMemento.cs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +using SkiaSharp; + +namespace LunaDraw.Logic.Utils; + +public class QuadTreeMemento(int level, SKRect bounds, Func getBounds) where T : class +{ + private readonly int maxObjects = 10; + private readonly int maxLevels = 5; + + private readonly int level = level; + private readonly List objects = []; + private readonly SKRect bounds = bounds; + private readonly Func getBounds = getBounds; + private QuadTreeMemento[]? nodes; + + public void Clear() + { + objects.Clear(); + + if (nodes != null) + { + foreach (var node in nodes) + { + node.Clear(); + } + nodes = null; + } + } + + private void Split() + { + float subWidth = bounds.Width / 2f; + float subHeight = bounds.Height / 2f; + float x = bounds.Left; + float y = bounds.Top; + + nodes = new QuadTreeMemento[4]; + nodes[0] = new QuadTreeMemento(level + 1, new SKRect(x + subWidth, y, x + subWidth + subWidth, y + subHeight), getBounds); + nodes[1] = new QuadTreeMemento(level + 1, new SKRect(x, y, x + subWidth, y + subHeight), getBounds); + nodes[2] = new QuadTreeMemento(level + 1, new SKRect(x, y + subHeight, x + subWidth, y + subHeight + subHeight), getBounds); + nodes[3] = new QuadTreeMemento(level + 1, new SKRect(x + subWidth, y + subHeight, x + subWidth + subWidth, y + subHeight + subHeight), getBounds); + } + + /* + * Index of the quadrant the object belongs to + */ + private int GetIndex(SKRect pRect) + { + int index = -1; + double verticalMidpoint = bounds.Left + (bounds.Width / 2f); + double horizontalMidpoint = bounds.Top + (bounds.Height / 2f); + + bool topQuadrant = pRect.Top < horizontalMidpoint && pRect.Bottom < horizontalMidpoint; + bool bottomQuadrant = pRect.Top > horizontalMidpoint; + + if (pRect.Left < verticalMidpoint && pRect.Right < verticalMidpoint) + { + if (topQuadrant) + { + index = 1; + } + else if (bottomQuadrant) + { + index = 2; + } + } + else if (pRect.Left > verticalMidpoint) + { + if (topQuadrant) + { + index = 0; + } + else if (bottomQuadrant) + { + index = 3; + } + } + + return index; + } + + public void Insert(T pObject) + { + if (nodes != null) + { + int index = GetIndex(getBounds(pObject)); + + if (index != -1) + { + nodes[index].Insert(pObject); + return; + } + } + + objects.Add(pObject); + + if (objects.Count > maxObjects && level < maxLevels) + { + if (nodes == null) + { + Split(); + } + + int i = 0; + while (i < objects.Count) + { + int index = GetIndex(getBounds(objects[i])); + if (index != -1) + { + nodes![index].Insert(objects[i]); + objects.RemoveAt(i); + } + else + { + i++; + } + } + } + } + + public List Retrieve(List returnObjects, SKRect pRect) + { + int index = GetIndex(pRect); + if (index != -1 && nodes != null) + { + nodes[index].Retrieve(returnObjects, pRect); + } + else if (nodes != null) + { + // If the rect doesn't fit into a specific quadrant (overlaps multiple), + // we must query all quadrants that it touches. + // Simplified: query all subnodes if we can't determine a single one. + // Or strictly check intersection. + // For now, naive approach: if it doesn't fit one, retrieve from all. + foreach (var node in nodes) + { + // Optimization: check intersection with node bounds + if (node.bounds.IntersectsWith(pRect)) + { + node.Retrieve(returnObjects, pRect); + } + } + } + + returnObjects.AddRange(objects); + + return returnObjects; + } +} \ No newline at end of file diff --git a/Logic/Utils/SelectionObserver.cs b/Logic/Utils/SelectionObserver.cs index fdccb7f..6251748 100644 --- a/Logic/Utils/SelectionObserver.cs +++ b/Logic/Utils/SelectionObserver.cs @@ -29,116 +29,115 @@ using SkiaSharp; -namespace LunaDraw.Logic.Managers -{ -public class SelectionObserver : ReactiveObject - { - private readonly ObservableCollection selected = []; - public ReadOnlyObservableCollection Selected { get; } +namespace LunaDraw.Logic.Utils; - public SelectionObserver() - { - Selected = new ReadOnlyObservableCollection(selected); - } +public class SelectionObserver : ReactiveObject +{ + private readonly ObservableCollection selected = []; + public ReadOnlyObservableCollection Selected { get; } - public void Clear() - { - if (selected.Count == 0) return; + public SelectionObserver() + { + Selected = new ReadOnlyObservableCollection(selected); + } - var elementsToClear = selected.ToList(); - selected.Clear(); + public void Clear() + { + if (selected.Count == 0) return; - foreach (var el in elementsToClear) - { - el.IsSelected = false; - } + var elementsToClear = selected.ToList(); + selected.Clear(); - OnSelectionChanged(); - } - - public void Add(IDrawableElement element) + foreach (var el in elementsToClear) { - if (element == null || selected.Contains(element)) return; - element.IsSelected = true; - selected.Add(element); - OnSelectionChanged(); + el.IsSelected = false; } - public void AddRange(IEnumerable elements) + OnSelectionChanged(); + } + + public void Add(IDrawableElement element) + { + if (element == null || selected.Contains(element)) return; + element.IsSelected = true; + selected.Add(element); + OnSelectionChanged(); + } + + public void AddRange(IEnumerable elements) + { + var changed = false; + foreach (var element in elements) { - var changed = false; - foreach (var element in elements) + if (element != null && !selected.Contains(element)) { - if (element != null && !selected.Contains(element)) - { - element.IsSelected = true; - selected.Add(element); - changed = true; - } + element.IsSelected = true; + selected.Add(element); + changed = true; } - - if (changed) - OnSelectionChanged(); } - public void Remove(IDrawableElement element) - { - if (element == null || !selected.Contains(element)) return; - element.IsSelected = false; - selected.Remove(element); + if (changed) OnSelectionChanged(); - } + } - public void Toggle(IDrawableElement element) - { - if (element == null) return; + public void Remove(IDrawableElement element) + { + if (element == null || !selected.Contains(element)) return; + element.IsSelected = false; + selected.Remove(element); + OnSelectionChanged(); + } - if (selected.Contains(element)) - { - Remove(element); - } - else - { - Add(element); - } - } + public void Toggle(IDrawableElement element) + { + if (element == null) return; - public bool Contains(IDrawableElement element) + if (selected.Contains(element)) { - return element != null && selected.Contains(element); + Remove(element); } - - public IReadOnlyList GetAll() + else { - return selected.ToList().AsReadOnly(); + Add(element); } + } - public SKRect GetBounds() - { - if (selected.Count == 0) - { - return SKRect.Empty; - } + public bool Contains(IDrawableElement element) + { + return element != null && selected.Contains(element); + } - var bounds = selected[0].Bounds; - for (var i = 1; i < selected.Count; i++) - { - bounds.Union(selected[i].Bounds); - } + public IReadOnlyList GetAll() + { + return selected.ToList().AsReadOnly(); + } - return bounds; + public SKRect GetBounds() + { + if (selected.Count == 0) + { + return SKRect.Empty; } - private void OnSelectionChanged() + var bounds = selected[0].Bounds; + for (var i = 1; i < selected.Count; i++) { - this.RaisePropertyChanged(nameof(Bounds)); - this.RaisePropertyChanged(nameof(HasSelection)); - SelectionChanged?.Invoke(this, EventArgs.Empty); + bounds.Union(selected[i].Bounds); } - public event EventHandler? SelectionChanged; + return bounds; + } - public SKRect Bounds => GetBounds(); - public bool HasSelection => selected.Count > 0; + private void OnSelectionChanged() + { + this.RaisePropertyChanged(nameof(Bounds)); + this.RaisePropertyChanged(nameof(HasSelection)); + SelectionChanged?.Invoke(this, EventArgs.Empty); } + + public event EventHandler? SelectionChanged; + + public SKRect Bounds => GetBounds(); + public bool HasSelection => selected.Count > 0; } \ No newline at end of file diff --git a/Logic/ViewModels/HistoryViewModel.cs b/Logic/ViewModels/HistoryViewModel.cs index 5534336..9df1988 100644 --- a/Logic/ViewModels/HistoryViewModel.cs +++ b/Logic/ViewModels/HistoryViewModel.cs @@ -22,75 +22,74 @@ */ using System.Reactive; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using ReactiveUI; using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class HistoryViewModel : ReactiveObject { - public class HistoryViewModel : ReactiveObject - { - private readonly HistoryMemento historyMemento; - private readonly ILayerFacade layerFacade; - private readonly IMessageBus messageBus; + private readonly HistoryMemento historyMemento; + private readonly ILayerFacade layerFacade; + private readonly IMessageBus messageBus; - public HistoryViewModel(ILayerFacade layerFacade, IMessageBus messageBus) - { - this.layerFacade = layerFacade; - historyMemento = layerFacade.HistoryMemento; - this.messageBus = messageBus; + public HistoryViewModel(ILayerFacade layerFacade, IMessageBus messageBus) + { + this.layerFacade = layerFacade; + historyMemento = layerFacade.HistoryMemento; + this.messageBus = messageBus; - // Observables for CanUndo/CanRedo - var canUndo = this.WhenAnyValue(x => x.historyMemento.CanUndo); - var canRedo = this.WhenAnyValue(x => x.historyMemento.CanRedo); + // Observables for CanUndo/CanRedo + var canUndo = this.WhenAnyValue(x => x.historyMemento.CanUndo); + var canRedo = this.WhenAnyValue(x => x.historyMemento.CanRedo); - UndoCommand = ReactiveCommand.Create(Undo, canUndo, RxApp.MainThreadScheduler); - RedoCommand = ReactiveCommand.Create(Redo, canRedo, RxApp.MainThreadScheduler); + UndoCommand = ReactiveCommand.Create(Undo, canUndo, RxApp.MainThreadScheduler); + RedoCommand = ReactiveCommand.Create(Redo, canRedo, RxApp.MainThreadScheduler); - // Expose properties for binding - canUndoProp = canUndo.ToProperty(this, x => x.CanUndo); - canRedoProp = canRedo.ToProperty(this, x => x.CanRedo); - } + // Expose properties for binding + canUndoProp = canUndo.ToProperty(this, x => x.CanUndo); + canRedoProp = canRedo.ToProperty(this, x => x.CanRedo); + } - private readonly ObservableAsPropertyHelper canUndoProp; - public bool CanUndo => canUndoProp.Value; + private readonly ObservableAsPropertyHelper canUndoProp; + public bool CanUndo => canUndoProp.Value; - private readonly ObservableAsPropertyHelper canRedoProp; - public bool CanRedo => canRedoProp.Value; + private readonly ObservableAsPropertyHelper canRedoProp; + public bool CanRedo => canRedoProp.Value; - public ReactiveCommand UndoCommand { get; } - public ReactiveCommand RedoCommand { get; } + public ReactiveCommand UndoCommand { get; } + public ReactiveCommand RedoCommand { get; } - private void Undo() - { - var state = historyMemento.Undo(); - if (state != null) - { - RestoreState(state); - } - } + private void Undo() + { + var state = historyMemento.Undo(); + if (state != null) + { + RestoreState(state); + } + } - private void Redo() - { - var state = historyMemento.Redo(); - if (state != null) - { - RestoreState(state); - } - } + private void Redo() + { + var state = historyMemento.Redo(); + if (state != null) + { + RestoreState(state); + } + } - private void RestoreState(List state) - { - layerFacade.Layers.Clear(); - foreach (var layer in state) - { - layerFacade.Layers.Add(layer); - } + private void RestoreState(List state) + { + layerFacade.Layers.Clear(); + foreach (var layer in state) + { + layerFacade.Layers.Add(layer); + } - var currentLayerId = layerFacade.CurrentLayer?.Id; - layerFacade.CurrentLayer = layerFacade.Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? layerFacade.Layers.FirstOrDefault(); + var currentLayerId = layerFacade.CurrentLayer?.Id; + layerFacade.CurrentLayer = layerFacade.Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? layerFacade.Layers.FirstOrDefault(); - messageBus.SendMessage(new LunaDraw.Logic.Messages.CanvasInvalidateMessage()); - } - } + messageBus.SendMessage(new LunaDraw.Logic.Messages.CanvasInvalidateMessage()); + } } diff --git a/Logic/ViewModels/LayerPanelViewModel.cs b/Logic/ViewModels/LayerPanelViewModel.cs index 2dca687..64a4b6b 100644 --- a/Logic/ViewModels/LayerPanelViewModel.cs +++ b/Logic/ViewModels/LayerPanelViewModel.cs @@ -25,96 +25,176 @@ using System.Collections.Specialized; using System.Reactive; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using ReactiveUI; +using System.Windows.Input; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class LayerPanelViewModel : ReactiveObject { - public class LayerPanelViewModel : ReactiveObject + private readonly ILayerFacade layerFacade; + private readonly IMessageBus messageBus; + private readonly IPreferencesFacade preferencesFacade; + + public LayerPanelViewModel(ILayerFacade layerFacade, IMessageBus messageBus, IPreferencesFacade preferencesFacade) + { + this.layerFacade = layerFacade; + this.messageBus = messageBus; + this.preferencesFacade = preferencesFacade; + layerFacade.WhenAnyValue(x => x.CurrentLayer) + .Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); + + // Commands + AddLayerCommand = ReactiveCommand.Create(() => + { + layerFacade.AddLayer(); + }, outputScheduler: RxApp.MainThreadScheduler); + + var layersChanged = Observable.FromEventPattern( + h => layerFacade.Layers.CollectionChanged += h, + h => layerFacade.Layers.CollectionChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default); + + var currentLayerChanged = layerFacade.WhenAnyValue(x => x.CurrentLayer) + .Select(_ => Unit.Default); + + var canRemoveLayer = Observable.Merge(layersChanged, currentLayerChanged) + .Select(_ => layerFacade.CurrentLayer != null && layerFacade.Layers.Count > 1) + .ObserveOn(RxApp.MainThreadScheduler); + + RemoveLayerCommand = ReactiveCommand.Create(() => + { + if (layerFacade.CurrentLayer != null) + { + layerFacade.RemoveLayer(layerFacade.CurrentLayer); + } + }, + canExecute: canRemoveLayer, + outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerForwardCommand = ReactiveCommand.Create(layer => + { + layerFacade.MoveLayerForward(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerBackwardCommand = ReactiveCommand.Create(layer => + { + layerFacade.MoveLayerBackward(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + ToggleLayerVisibilityCommand = ReactiveCommand.Create(layer => + { + if (layer != null) + { + layer.IsVisible = !layer.IsVisible; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + }, outputScheduler: RxApp.MainThreadScheduler); + + ToggleLayerLockCommand = ReactiveCommand.Create(layer => + { + if (layer != null) + { + layer.IsLocked = !layer.IsLocked; + } + }, outputScheduler: RxApp.MainThreadScheduler); + + ToggleTraceModeCommand = ReactiveCommand.Create(() => + { + if (IsTransparentBackground) + { + WindowTransparency = 255; + } + else + { + WindowTransparency = 125; + } + + messageBus.SendMessage(new CanvasInvalidateMessage()); + }, outputScheduler: RxApp.MainThreadScheduler); + + // Initialize state from Preferences + IsTransparentBackground = preferencesFacade.Get(AppPreference.IsTransparentBackgroundEnabled); + if (!IsTransparentBackground) + { + windowTransparency = 255; + } + } + + public ObservableCollection Layers => layerFacade.Layers; + + public Layer? CurrentLayer + { + get => layerFacade.CurrentLayer; + set => layerFacade.CurrentLayer = value; + } + + public ReactiveCommand AddLayerCommand { get; } + public ReactiveCommand RemoveLayerCommand { get; } + public ReactiveCommand MoveLayerForwardCommand { get; } + public ReactiveCommand MoveLayerBackwardCommand { get; } + public ReactiveCommand ToggleLayerVisibilityCommand { get; } + public ReactiveCommand ToggleLayerLockCommand { get; } + public ReactiveCommand ToggleTraceModeCommand { get; } + + private bool isTransparentBackground = false; + public bool IsTransparentBackground + { + get => isTransparentBackground; + set + { + this.RaiseAndSetIfChanged(ref isTransparentBackground, value); + preferencesFacade.Set(AppPreference.IsTransparentBackgroundEnabled, value); + + if (!isTransparentBackground) + { + WindowTransparency = 255; + UpdateWindowTransparency(); + } + + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + } + + private byte windowTransparency = 180; + public virtual byte WindowTransparency + { + get => windowTransparency; + set + { + this.RaiseAndSetIfChanged(ref windowTransparency, value); + if (value == 255 && isTransparentBackground) + { + IsTransparentBackground = false; + UpdateWindowTransparency(); + } + else if (value < 255 && !isTransparentBackground) + { + IsTransparentBackground = true; + } + + if (IsTransparentBackground) + { + UpdateWindowTransparency(); + } + } + } + + private void UpdateWindowTransparency() + { +#if WINDOWS + if (IsTransparentBackground) + { + LunaDraw.PlatformHelper.EnableTrueTransparency(WindowTransparency); + } + else { - private readonly ILayerFacade layerFacade; - private readonly IMessageBus messageBus; - - public LayerPanelViewModel(ILayerFacade layerFacade, IMessageBus messageBus) - { - this.layerFacade = layerFacade; - this.messageBus = messageBus; - - layerFacade.WhenAnyValue(x => x.CurrentLayer) - .Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); - - // Commands - AddLayerCommand = ReactiveCommand.Create(() => - { - layerFacade.AddLayer(); - }, outputScheduler: RxApp.MainThreadScheduler); - - var layersChanged = Observable.FromEventPattern( - h => layerFacade.Layers.CollectionChanged += h, - h => layerFacade.Layers.CollectionChanged -= h) - .Select(_ => Unit.Default) - .StartWith(Unit.Default); - - var currentLayerChanged = layerFacade.WhenAnyValue(x => x.CurrentLayer) - .Select(_ => Unit.Default); - - var canRemoveLayer = Observable.Merge(layersChanged, currentLayerChanged) - .Select(_ => layerFacade.CurrentLayer != null && layerFacade.Layers.Count > 1) - .ObserveOn(RxApp.MainThreadScheduler); - - RemoveLayerCommand = ReactiveCommand.Create(() => - { - if (layerFacade.CurrentLayer != null) - { - layerFacade.RemoveLayer(layerFacade.CurrentLayer); - } - }, - canExecute: canRemoveLayer, - outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerForwardCommand = ReactiveCommand.Create(layer => - { - layerFacade.MoveLayerForward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerBackwardCommand = ReactiveCommand.Create(layer => - { - layerFacade.MoveLayerBackward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - ToggleLayerVisibilityCommand = ReactiveCommand.Create(layer => - { - if (layer != null) - { - layer.IsVisible = !layer.IsVisible; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - }, outputScheduler: RxApp.MainThreadScheduler); - - ToggleLayerLockCommand = ReactiveCommand.Create(layer => - { - if (layer != null) - { - layer.IsLocked = !layer.IsLocked; - } - }, outputScheduler: RxApp.MainThreadScheduler); - } - - public ObservableCollection Layers => layerFacade.Layers; - - public Layer? CurrentLayer - { - get => layerFacade.CurrentLayer; - set => layerFacade.CurrentLayer = value; - } - - public ReactiveCommand AddLayerCommand { get; } - public ReactiveCommand RemoveLayerCommand { get; } - public ReactiveCommand MoveLayerForwardCommand { get; } - public ReactiveCommand MoveLayerBackwardCommand { get; } - public ReactiveCommand ToggleLayerVisibilityCommand { get; } - public ReactiveCommand ToggleLayerLockCommand { get; } + LunaDraw.PlatformHelper.EnableTrueTransparency(255); } +#endif + } } diff --git a/Logic/ViewModels/MainViewModel.cs b/Logic/ViewModels/MainViewModel.cs index 78b10d0..daa21e9 100644 --- a/Logic/ViewModels/MainViewModel.cs +++ b/Logic/ViewModels/MainViewModel.cs @@ -22,108 +22,235 @@ */ using System.Collections.ObjectModel; -using System.Reactive; +using System.Windows.Input; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.Services; using LunaDraw.Logic.Tools; using ReactiveUI; using SkiaSharp; using SkiaSharp.Views.Maui; +using CommunityToolkit.Maui.Extensions; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class MainViewModel : ReactiveObject { - public class MainViewModel( - ToolbarViewModel toolbarViewModel, - ILayerFacade layerFacade, - ICanvasInputHandler canvasInputHandler, - NavigationModel navigationModel, - SelectionObserver selectionObserver, - IMessageBus messageBus, - LayerPanelViewModel layerPanelVM, - SelectionViewModel selectionVM, - HistoryViewModel historyVM) : ReactiveObject - { - // Dependencies - public ToolbarViewModel ToolbarViewModel { get; } = toolbarViewModel; - public ILayerFacade LayerFacade { get; } = layerFacade; - public ICanvasInputHandler CanvasInputHandler { get; } = canvasInputHandler; - public NavigationModel NavigationModel { get; } = navigationModel; - public SelectionObserver SelectionObserver { get; } = selectionObserver; - private readonly IMessageBus messageBus = messageBus; + // Dependencies + public ToolbarViewModel ToolbarViewModel { get; } + public ILayerFacade LayerFacade { get; } + public ICanvasInputHandler CanvasInputHandler { get; } + public NavigationModel NavigationModel { get; } + public SelectionObserver SelectionObserver { get; } + private readonly IMessageBus messageBus; + private readonly IPreferencesFacade preferencesFacade; - // Sub-ViewModels - public LayerPanelViewModel LayerPanelVM { get; } = layerPanelVM; - public SelectionViewModel SelectionVM { get; } = selectionVM; - public HistoryViewModel HistoryVM { get; } = historyVM; + // Sub-ViewModels + public LayerPanelViewModel LayerPanelVM { get; } + public SelectionViewModel SelectionVM { get; } + public HistoryViewModel HistoryVM { get; } - public SKRect CanvasSize { get; set; } + // Commands + public ICommand ZoomInCommand { get; } + public ICommand ZoomOutCommand { get; } + public ICommand ResetZoomCommand { get; } - // Facades for View/CodeBehind access - public ObservableCollection Layers => LayerFacade.Layers; + public SKRect CanvasSize { get; set; } - public Layer? CurrentLayer + // UI State + public List AvailableThemes { get; } = new List { "Automatic", "Light", "Dark" }; + + private string selectedTheme = "Automatic"; + public string SelectedTheme + { + get => selectedTheme; + set { - get => LayerFacade.CurrentLayer; - set => LayerFacade.CurrentLayer = value; + this.RaiseAndSetIfChanged(ref selectedTheme, value); + preferencesFacade.Set(AppPreference.AppTheme, value); + UpdateAppTheme(value); } + } + + private bool showButtonLabels; - public IDrawingTool ActiveTool + public bool ShowButtonLabels + { + get => showButtonLabels; + set { - get => ToolbarViewModel.ActiveTool; - set => ToolbarViewModel.ActiveTool = value; + this.RaiseAndSetIfChanged(ref showButtonLabels, value); + preferencesFacade.Set(AppPreference.ShowButtonLabels, value); + messageBus.SendMessage(new ViewOptionsChangedMessage(value, ShowLayersPanel)); } + } + + private bool showLayersPanel; + public bool ShowLayersPanel + { + get => showLayersPanel; + set + { + this.RaiseAndSetIfChanged(ref showLayersPanel, value); + preferencesFacade.Set(AppPreference.ShowLayersPanel, value); + messageBus.SendMessage(new ViewOptionsChangedMessage(ShowButtonLabels, value)); + } + } - public ReadOnlyObservableCollection SelectedElements => SelectionObserver.Selected; + // Facades for View/CodeBehind access + public ObservableCollection Layers => LayerFacade.Layers; - public void ReorderLayer(Layer source, Layer target) + public Layer? CurrentLayer + { + get => LayerFacade.CurrentLayer; + set => LayerFacade.CurrentLayer = value; + } + + public MainViewModel( + ToolbarViewModel toolbarViewModel, + ILayerFacade layerFacade, + ICanvasInputHandler canvasInputHandler, + NavigationModel navigationModel, + SelectionObserver selectionObserver, + IMessageBus messageBus, + IPreferencesFacade preferencesFacade, + LayerPanelViewModel layerPanelVM, + SelectionViewModel selectionVM, + HistoryViewModel historyVM) + { + ToolbarViewModel = toolbarViewModel; + LayerFacade = layerFacade; + CanvasInputHandler = canvasInputHandler; + NavigationModel = navigationModel; + SelectionObserver = selectionObserver; + this.messageBus = messageBus; + this.preferencesFacade = preferencesFacade; + LayerPanelVM = layerPanelVM; + SelectionVM = selectionVM; + HistoryVM = historyVM; + + // Use Property setters to trigger ViewOptionsChangedMessage so ToolbarViewModel syncs up + ShowButtonLabels = this.preferencesFacade.Get(AppPreference.ShowButtonLabels); + ShowLayersPanel = this.preferencesFacade.Get(AppPreference.ShowLayersPanel); + var savedTheme = this.preferencesFacade.Get(AppPreference.AppTheme); + SelectedTheme = AvailableThemes.FirstOrDefault(t => t == savedTheme) ?? AvailableThemes[0]; + + ZoomInCommand = ReactiveCommand.Create(ZoomIn); + ZoomOutCommand = ReactiveCommand.Create(ZoomOut); + ResetZoomCommand = ReactiveCommand.Create(ResetZoom); + + // Listen for ShowAdvancedSettingsMessage + this.messageBus.Listen().Subscribe(async _ => { - if (source == null || target == null || source == target) return; - int oldIndex = Layers.IndexOf(source); - int newIndex = Layers.IndexOf(target); - if (oldIndex >= 0 && newIndex >= 0) + var popup = new Components.AdvancedSettingsPopup(this); + var page = Application.Current?.Windows[0]?.Page; + if (page != null) { - LayerFacade.MoveLayer(oldIndex, newIndex); - CurrentLayer = source; + await page.ShowPopupAsync(popup); } - } + }); + } + + public IDrawingTool ActiveTool + { + get => ToolbarViewModel.ActiveTool; + set => ToolbarViewModel.ActiveTool = value; + } - public void ProcessTouch(SKTouchEventArgs e) + public ReadOnlyObservableCollection SelectedElements => SelectionObserver.Selected; + + public void ReorderLayer(Layer source, Layer target) + { + if (source == null || target == null || source == target) return; + int oldIndex = Layers.IndexOf(source); + int newIndex = Layers.IndexOf(target); + if (oldIndex >= 0 && newIndex >= 0) { - CanvasInputHandler.ProcessTouch(e, CanvasSize); + LayerFacade.MoveLayer(oldIndex, newIndex); + CurrentLayer = source; } + } - public ToolContext CreateToolContext() + public void ProcessTouch(SKTouchEventArgs e) + { + CanvasInputHandler.ProcessTouch(e, CanvasSize); + } + + public ToolContext CreateToolContext() + { + return new ToolContext { - return new ToolContext + CurrentLayer = LayerFacade.CurrentLayer!, + StrokeColor = ToolbarViewModel.StrokeColor, + FillColor = ToolbarViewModel.FillColor, + StrokeWidth = ToolbarViewModel.StrokeWidth, + Opacity = ToolbarViewModel.Opacity, + Flow = ToolbarViewModel.Flow, + Spacing = ToolbarViewModel.Spacing, + BrushShape = ToolbarViewModel.CurrentBrushShape, + AllElements = LayerFacade.Layers.SelectMany(l => l.Elements), + Layers = LayerFacade.Layers, + SelectionObserver = SelectionObserver, + Scale = NavigationModel.ViewMatrix.ScaleX, + IsGlowEnabled = ToolbarViewModel.IsGlowEnabled, + GlowColor = ToolbarViewModel.GlowColor, + GlowRadius = ToolbarViewModel.GlowRadius, + IsRainbowEnabled = ToolbarViewModel.IsRainbowEnabled, + ScatterRadius = ToolbarViewModel.ScatterRadius, + SizeJitter = ToolbarViewModel.SizeJitter, + AngleJitter = ToolbarViewModel.AngleJitter, + HueJitter = ToolbarViewModel.HueJitter, + CanvasMatrix = NavigationModel.ViewMatrix + }; + } + + private void UpdateAppTheme(string theme) + { + if (Application.Current != null) + { + Application.Current.UserAppTheme = theme switch { - CurrentLayer = LayerFacade.CurrentLayer!, - StrokeColor = ToolbarViewModel.StrokeColor, - FillColor = ToolbarViewModel.FillColor, - StrokeWidth = ToolbarViewModel.StrokeWidth, - Opacity = ToolbarViewModel.Opacity, - Flow = ToolbarViewModel.Flow, - Spacing = ToolbarViewModel.Spacing, - BrushShape = ToolbarViewModel.CurrentBrushShape, - AllElements = LayerFacade.Layers.SelectMany(l => l.Elements), - Layers = LayerFacade.Layers, - SelectionObserver = SelectionObserver, - Scale = NavigationModel.ViewMatrix.ScaleX, - IsGlowEnabled = ToolbarViewModel.IsGlowEnabled, - GlowColor = ToolbarViewModel.GlowColor, - GlowRadius = ToolbarViewModel.GlowRadius, - IsRainbowEnabled = ToolbarViewModel.IsRainbowEnabled, - ScatterRadius = ToolbarViewModel.ScatterRadius, - SizeJitter = ToolbarViewModel.SizeJitter, - AngleJitter = ToolbarViewModel.AngleJitter, - HueJitter = ToolbarViewModel.HueJitter, - CanvasMatrix = NavigationModel.ViewMatrix + "Light" => AppTheme.Light, + "Dark" => AppTheme.Dark, + _ => AppTheme.Unspecified }; } + + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private void ZoomIn() => Zoom(1.2f); + private void ZoomOut() => Zoom(1f / 1.2f); + + private void ResetZoom() + { + NavigationModel.Reset(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private void Zoom(float scaleFactor) + { + if (CanvasSize.Width <= 0 || CanvasSize.Height <= 0) return; + + var currentScale = NavigationModel.ViewMatrix.ScaleX; + var newScale = currentScale * scaleFactor; + + // Clamp scale + if (newScale < 0.1f) scaleFactor = 0.1f / currentScale; + if (newScale > 20.0f) scaleFactor = 20.0f / currentScale; + + var center = new SKPoint(CanvasSize.Width / 2, CanvasSize.Height / 2); + + // Scale around center + var zoomMatrix = SKMatrix.CreateScale(scaleFactor, scaleFactor, center.X, center.Y); + + // Apply to existing view matrix + NavigationModel.ViewMatrix = SKMatrix.Concat(zoomMatrix, NavigationModel.ViewMatrix); + + messageBus.SendMessage(new CanvasInvalidateMessage()); } } \ No newline at end of file diff --git a/Logic/ViewModels/SelectionViewModel.cs b/Logic/ViewModels/SelectionViewModel.cs index 14d6d49..a999c49 100644 --- a/Logic/ViewModels/SelectionViewModel.cs +++ b/Logic/ViewModels/SelectionViewModel.cs @@ -24,272 +24,279 @@ using System.Collections.ObjectModel; using System.Reactive; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class SelectionViewModel : ReactiveObject { - public class SelectionViewModel : ReactiveObject + private readonly SelectionObserver selectionObserver; + private readonly ILayerFacade layerFacade; + private readonly ClipboardMemento clipboardManager; + private readonly IMessageBus messageBus; + + public SelectionViewModel( + SelectionObserver selectionObserver, + ILayerFacade layerFacade, + ClipboardMemento clipboardManager, + IMessageBus messageBus) { - private readonly SelectionObserver selectionObserver; - private readonly ILayerFacade layerFacade; - private readonly ClipboardMemento clipboardManager; - private readonly IMessageBus messageBus; - - public SelectionViewModel( - SelectionObserver selectionObserver, - ILayerFacade layerFacade, - ClipboardMemento clipboardManager, - IMessageBus messageBus) - { - this.selectionObserver = selectionObserver; - this.layerFacade = layerFacade; - this.clipboardManager = clipboardManager; - this.messageBus = messageBus; - - // OAPHs - var hasSelection = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count > 0); - - canDelete = hasSelection.ToProperty(this, x => x.CanDelete); - - canGroup = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count > 1) - .ToProperty(this, x => x.CanGroup); - - canUngroup = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count == 1 && SelectedElements.FirstOrDefault() is DrawableGroup) - .ToProperty(this, x => x.CanUngroup); - - canPaste = this.WhenAnyValue(x => x.clipboardManager.HasItems) - .ToProperty(this, x => x.CanPaste); - - // Commands - DeleteSelectedCommand = ReactiveCommand.Create(DeleteSelected, hasSelection, RxApp.MainThreadScheduler); - GroupSelectedCommand = ReactiveCommand.Create(GroupSelected, this.WhenAnyValue(x => x.CanGroup), RxApp.MainThreadScheduler); - UngroupSelectedCommand = ReactiveCommand.Create(UngroupSelected, this.WhenAnyValue(x => x.CanUngroup), RxApp.MainThreadScheduler); - CopyCommand = ReactiveCommand.Create(Copy, hasSelection, RxApp.MainThreadScheduler); - CutCommand = ReactiveCommand.Create(Cut, hasSelection, RxApp.MainThreadScheduler); - PasteCommand = ReactiveCommand.Create(Paste, this.WhenAnyValue(x => x.CanPaste), RxApp.MainThreadScheduler); - - SendBackwardCommand = ReactiveCommand.Create(SendBackward, hasSelection, RxApp.MainThreadScheduler); - BringForwardCommand = ReactiveCommand.Create(BringForward, hasSelection, RxApp.MainThreadScheduler); - SendElementToBackCommand = ReactiveCommand.Create(SendElementToBack, hasSelection, RxApp.MainThreadScheduler); - BringElementToFrontCommand = ReactiveCommand.Create(BringElementToFront, hasSelection, RxApp.MainThreadScheduler); - MoveSelectionToLayerCommand = ReactiveCommand.Create(MoveSelectionToLayer, hasSelection, RxApp.MainThreadScheduler); - MoveSelectionToNewLayerCommand = ReactiveCommand.Create(MoveSelectionToNewLayer, hasSelection, RxApp.MainThreadScheduler); - } + this.selectionObserver = selectionObserver; + this.layerFacade = layerFacade; + this.clipboardManager = clipboardManager; + this.messageBus = messageBus; + + // OAPHs + var hasSelection = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count > 0); + + canDelete = hasSelection.ToProperty(this, x => x.CanDelete); + + canGroup = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count > 1) + .ToProperty(this, x => x.CanGroup); + + canUngroup = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count == 1 && SelectedElements.FirstOrDefault() is DrawableGroup) + .ToProperty(this, x => x.CanUngroup); + + canPaste = this.WhenAnyValue(x => x.clipboardManager.HasItems) + .ToProperty(this, x => x.CanPaste); + + // Commands + DeleteSelectedCommand = ReactiveCommand.Create(DeleteSelected, hasSelection, RxApp.MainThreadScheduler); + GroupSelectedCommand = ReactiveCommand.Create(GroupSelected, this.WhenAnyValue(x => x.CanGroup), RxApp.MainThreadScheduler); + UngroupSelectedCommand = ReactiveCommand.Create(UngroupSelected, this.WhenAnyValue(x => x.CanUngroup), RxApp.MainThreadScheduler); + CopyCommand = ReactiveCommand.Create(Copy, hasSelection, RxApp.MainThreadScheduler); + CutCommand = ReactiveCommand.Create(Cut, hasSelection, RxApp.MainThreadScheduler); + PasteCommand = ReactiveCommand.Create(Paste, this.WhenAnyValue(x => x.CanPaste), RxApp.MainThreadScheduler); + DuplicateCommand = ReactiveCommand.Create(Duplicate, hasSelection, RxApp.MainThreadScheduler); + + SendBackwardCommand = ReactiveCommand.Create(SendBackward, hasSelection, RxApp.MainThreadScheduler); + BringForwardCommand = ReactiveCommand.Create(BringForward, hasSelection, RxApp.MainThreadScheduler); + SendElementToBackCommand = ReactiveCommand.Create(SendElementToBack, hasSelection, RxApp.MainThreadScheduler); + BringElementToFrontCommand = ReactiveCommand.Create(BringElementToFront, hasSelection, RxApp.MainThreadScheduler); + MoveSelectionToLayerCommand = ReactiveCommand.Create(MoveSelectionToLayer, hasSelection, RxApp.MainThreadScheduler); + MoveSelectionToNewLayerCommand = ReactiveCommand.Create(MoveSelectionToNewLayer, hasSelection, RxApp.MainThreadScheduler); + } - public ReadOnlyObservableCollection SelectedElements => selectionObserver.Selected; + public ReadOnlyObservableCollection SelectedElements => selectionObserver.Selected; - private readonly ObservableAsPropertyHelper canDelete; - public bool CanDelete => canDelete.Value; + private readonly ObservableAsPropertyHelper canDelete; + public bool CanDelete => canDelete.Value; - private readonly ObservableAsPropertyHelper canGroup; - public bool CanGroup => canGroup.Value; + private readonly ObservableAsPropertyHelper canGroup; + public bool CanGroup => canGroup.Value; - private readonly ObservableAsPropertyHelper canUngroup; - public bool CanUngroup => canUngroup.Value; + private readonly ObservableAsPropertyHelper canUngroup; + public bool CanUngroup => canUngroup.Value; - private readonly ObservableAsPropertyHelper canPaste; - public bool CanPaste => canPaste.Value; + private readonly ObservableAsPropertyHelper canPaste; + public bool CanPaste => canPaste.Value; - public ReactiveCommand DeleteSelectedCommand { get; } - public ReactiveCommand GroupSelectedCommand { get; } - public ReactiveCommand UngroupSelectedCommand { get; } - public ReactiveCommand CopyCommand { get; } - public ReactiveCommand CutCommand { get; } - public ReactiveCommand PasteCommand { get; } - public ReactiveCommand SendBackwardCommand { get; } - public ReactiveCommand BringForwardCommand { get; } - public ReactiveCommand SendElementToBackCommand { get; } - public ReactiveCommand BringElementToFrontCommand { get; } - public ReactiveCommand MoveSelectionToLayerCommand { get; } - public ReactiveCommand MoveSelectionToNewLayerCommand { get; } + public ReactiveCommand DeleteSelectedCommand { get; } + public ReactiveCommand GroupSelectedCommand { get; } + public ReactiveCommand UngroupSelectedCommand { get; } + public ReactiveCommand CopyCommand { get; } + public ReactiveCommand CutCommand { get; } + public ReactiveCommand PasteCommand { get; } + public ReactiveCommand DuplicateCommand { get; } + public ReactiveCommand SendBackwardCommand { get; } + public ReactiveCommand BringForwardCommand { get; } + public ReactiveCommand SendElementToBackCommand { get; } + public ReactiveCommand BringElementToFrontCommand { get; } + public ReactiveCommand MoveSelectionToLayerCommand { get; } + public ReactiveCommand MoveSelectionToNewLayerCommand { get; } - private void MoveSelectionToNewLayer() - { - if (!SelectedElements.Any()) return; + private void MoveSelectionToNewLayer() + { + if (!SelectedElements.Any()) return; - layerFacade.AddLayer(); - var newLayer = layerFacade.CurrentLayer; + layerFacade.AddLayer(); + var newLayer = layerFacade.CurrentLayer; - if (newLayer != null) - { - layerFacade.MoveElementsToLayer(SelectedElements, newLayer); - } + if (newLayer != null) + { + layerFacade.MoveElementsToLayer(SelectedElements, newLayer); } + } + + private void MoveSelectionToLayer(Layer targetLayer) + { + if (targetLayer == null || !SelectedElements.Any()) return; + layerFacade.MoveElementsToLayer(SelectedElements, targetLayer); + } + + private void DeleteSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; - private void MoveSelectionToLayer(Layer targetLayer) + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) { - if (targetLayer == null || !SelectedElements.Any()) return; - layerFacade.MoveElementsToLayer(SelectedElements, targetLayer); + currentLayer.Elements.Remove(element); } + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void DeleteSelected() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; + private void GroupSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - currentLayer.Elements.Remove(element); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + var elementsToGroup = SelectedElements.ToList(); + var group = new DrawableGroup(); - private void GroupSelected() + foreach (var element in elementsToGroup) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; - - var elementsToGroup = SelectedElements.ToList(); - var group = new DrawableGroup(); + currentLayer.Elements.Remove(element); + group.Children.Add(element); + } + currentLayer.Elements.Add(group); + selectionObserver.Clear(); + selectionObserver.Add(group); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - foreach (var element in elementsToGroup) + private void UngroupSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null) return; + var group = SelectedElements.FirstOrDefault() as DrawableGroup; + if (group != null) + { + currentLayer.Elements.Remove(group); + foreach (var child in group.Children) { - currentLayer.Elements.Remove(element); - group.Children.Add(element); + currentLayer.Elements.Add(child); } - currentLayer.Elements.Add(group); selectionObserver.Clear(); - selectionObserver.Add(group); messageBus.SendMessage(new CanvasInvalidateMessage()); layerFacade.SaveState(); } + } + + private void Copy() + { + clipboardManager.Copy(SelectedElements); + } - private void UngroupSelected() + private void Cut() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; + clipboardManager.Copy(SelectedElements); + + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null) return; - var group = SelectedElements.FirstOrDefault() as DrawableGroup; - if (group != null) - { - currentLayer.Elements.Remove(group); - foreach (var child in group.Children) - { - currentLayer.Elements.Add(child); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + currentLayer.Elements.Remove(element); } + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void Copy() + private void Paste() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !clipboardManager.HasItems) return; + foreach (var element in clipboardManager.Paste()) { - clipboardManager.Copy(SelectedElements); + element.Translate(new SKPoint(10, 10)); // Offset pasted element + currentLayer.Elements.Add(element); } + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void Cut() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; - clipboardManager.Copy(SelectedElements); + private void Duplicate() + { + Copy(); + Paste(); + } - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - currentLayer.Elements.Remove(element); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + private void SendBackward() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - private void Paste() + if (index > 0) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !clipboardManager.HasItems) return; - foreach (var element in clipboardManager.Paste()) - { - element.Translate(new SKPoint(10, 10)); // Offset pasted element - currentLayer.Elements.Add(element); - } + currentLayer.Elements.Move(index, index - 1); + ReassignZIndices(currentLayer.Elements); messageBus.SendMessage(new CanvasInvalidateMessage()); layerFacade.SaveState(); } + } - private void SendBackward() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + private void BringForward() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - if (index > 0) - { - currentLayer.Elements.Move(index, index - 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } - } + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - private void BringForward() + if (index < currentLayer.Elements.Count - 1) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); - - if (index < currentLayer.Elements.Count - 1) - { - currentLayer.Elements.Move(index, index + 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + currentLayer.Elements.Move(index, index + 1); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private void SendElementToBack() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; + private void SendElementToBack() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - if (index > 0) - { - currentLayer.Elements.Move(index, 0); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + if (index > 0) + { + currentLayer.Elements.Move(index, 0); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private void BringElementToFront() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; + private void BringElementToFront() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - if (index < currentLayer.Elements.Count - 1) - { - currentLayer.Elements.Move(index, currentLayer.Elements.Count - 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + if (index < currentLayer.Elements.Count - 1) + { + currentLayer.Elements.Move(index, currentLayer.Elements.Count - 1); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private static void ReassignZIndices(IList elements) + private static void ReassignZIndices(IList elements) + { + for (int i = 0; i < elements.Count; i++) { - for (int i = 0; i < elements.Count; i++) - { - elements[i].ZIndex = i; - } + elements[i].ZIndex = i; } } } diff --git a/Logic/ViewModels/ToolbarViewModel.cs b/Logic/ViewModels/ToolbarViewModel.cs index 2d847a1..42c02c0 100644 --- a/Logic/ViewModels/ToolbarViewModel.cs +++ b/Logic/ViewModels/ToolbarViewModel.cs @@ -23,12 +23,10 @@ using System.Reactive; using System.Reactive.Linq; -using System.Threading.Tasks; using CommunityToolkit.Maui.Storage; -using Microsoft.Maui.Storage; using LunaDraw.Logic.Models; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Tools; @@ -36,493 +34,529 @@ using SkiaSharp; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class ToolbarViewModel : ReactiveObject { - public class ToolbarViewModel : ReactiveObject + private readonly ILayerFacade layerFacade; + private readonly SelectionViewModel selectionVM; + private readonly HistoryViewModel historyVM; + private readonly IMessageBus messageBus; + private readonly IBitmapCache bitmapCacheManager; + private readonly NavigationModel navigationModel; + private readonly IFileSaver fileSaver; + + // Tool State Properties + private IDrawingTool activeTool; + public virtual IDrawingTool ActiveTool + { + get => activeTool; + set { - private readonly ILayerFacade layerFacade; - private readonly SelectionViewModel selectionVM; - private readonly HistoryViewModel historyVM; - private readonly IMessageBus messageBus; - private readonly IBitmapCache bitmapCacheManager; - private readonly NavigationModel navigationModel; - private readonly IFileSaver fileSaver; - - // Tool State Properties - private IDrawingTool activeTool; - public virtual IDrawingTool ActiveTool - { - get => activeTool; - set - { - this.RaiseAndSetIfChanged(ref activeTool, value); - messageBus.SendMessage(new ToolChangedMessage(value)); - } - } - - private SKColor strokeColor = SKColors.MediumPurple; - public virtual SKColor StrokeColor - { - get => strokeColor; - set => this.RaiseAndSetIfChanged(ref strokeColor, value); - } - - private SKColor? fillColor = SKColors.SteelBlue; - public virtual SKColor? FillColor - { - get => fillColor; - set => this.RaiseAndSetIfChanged(ref fillColor, value); - } - - private float strokeWidth = 40; - public virtual float StrokeWidth - { - get => strokeWidth; - set => this.RaiseAndSetIfChanged(ref strokeWidth, value); - } + this.RaiseAndSetIfChanged(ref activeTool, value); + messageBus.SendMessage(new ToolChangedMessage(value)); + } + } + + private SKColor strokeColor = SKColors.MediumPurple; + public virtual SKColor StrokeColor + { + get => strokeColor; + set => this.RaiseAndSetIfChanged(ref strokeColor, value); + } + + private SKColor? fillColor = SKColors.SteelBlue; + public virtual SKColor? FillColor + { + get => fillColor; + set => this.RaiseAndSetIfChanged(ref fillColor, value); + } + + private float strokeWidth = 40; + public virtual float StrokeWidth + { + get => strokeWidth; + set => this.RaiseAndSetIfChanged(ref strokeWidth, value); + } + + private byte opacity = 255; + public virtual byte Opacity + { + get => opacity; + set => this.RaiseAndSetIfChanged(ref opacity, value); + } + + private byte flow = 255; + public virtual byte Flow + { + get => flow; + set => this.RaiseAndSetIfChanged(ref flow, value); + } + + private float spacing = 1f; + public virtual float Spacing + { + get => spacing; + set => this.RaiseAndSetIfChanged(ref spacing, value); + } + + private BrushShape currentBrushShape; + public virtual BrushShape CurrentBrushShape + { + get => currentBrushShape; + set => this.RaiseAndSetIfChanged(ref currentBrushShape, value); + } + + private bool isGlowEnabled = false; + public virtual bool IsGlowEnabled + { + get => isGlowEnabled; + set => this.RaiseAndSetIfChanged(ref isGlowEnabled, value); + } + + private SKColor glowColor = SKColors.Yellow; + public virtual SKColor GlowColor + { + get => glowColor; + set => this.RaiseAndSetIfChanged(ref glowColor, value); + } + + private float glowRadius = 10f; + public virtual float GlowRadius + { + get => glowRadius; + set => this.RaiseAndSetIfChanged(ref glowRadius, value); + } + + private bool isRainbowEnabled; + public virtual bool IsRainbowEnabled + { + get => isRainbowEnabled; + set => this.RaiseAndSetIfChanged(ref isRainbowEnabled, value); + } + + private float scatterRadius; + public virtual float ScatterRadius + { + get => scatterRadius; + set => this.RaiseAndSetIfChanged(ref scatterRadius, value); + } + + private float sizeJitter; + public virtual float SizeJitter + { + get => sizeJitter; + set => this.RaiseAndSetIfChanged(ref sizeJitter, value); + } + + private float angleJitter; + public virtual float AngleJitter + { + get => angleJitter; + set => this.RaiseAndSetIfChanged(ref angleJitter, value); + } + + private float hueJitter; + public virtual float HueJitter + { + get => hueJitter; + set => this.RaiseAndSetIfChanged(ref hueJitter, value); + } + + public List AvailableTools { get; } + public List AvailableBrushShapes { get; } + + // Delegated Commands + public ReactiveCommand SelectToolCommand { get; } + public ReactiveCommand UndoCommand => historyVM.UndoCommand; + public ReactiveCommand RedoCommand => historyVM.RedoCommand; + public ReactiveCommand CopyCommand => selectionVM.CopyCommand; + public ReactiveCommand PasteCommand => selectionVM.PasteCommand; + public ReactiveCommand DeleteSelectedCommand => selectionVM.DeleteSelectedCommand; + public ReactiveCommand GroupSelectedCommand => selectionVM.GroupSelectedCommand; + public ReactiveCommand UngroupSelectedCommand => selectionVM.UngroupSelectedCommand; + + // Local Commands + public ReactiveCommand ShowSettingsCommand { get; } + public ReactiveCommand ShowShapesFlyoutCommand { get; } + public ReactiveCommand SelectRectangleCommand { get; } + public ReactiveCommand SelectCircleCommand { get; } + public ReactiveCommand SelectLineCommand { get; } + public ReactiveCommand ShowBrushesFlyoutCommand { get; } + public ReactiveCommand SelectBrushShapeCommand { get; } + public ReactiveCommand ImportImageCommand { get; } + public ReactiveCommand SaveImageCommand { get; } + public ReactiveCommand ShowAdvancedSettingsCommand { get; } + + // UI state properties + private bool isSettingsOpen = false; + public bool IsSettingsOpen + { + get => isSettingsOpen; + set => this.RaiseAndSetIfChanged(ref isSettingsOpen, value); + } + + private bool showButtonLabels = true; + public bool ShowButtonLabels + { + get => showButtonLabels; + set => this.RaiseAndSetIfChanged(ref showButtonLabels, value); + } + + private bool isShapesFlyoutOpen = false; + public bool IsShapesFlyoutOpen + { + get => isShapesFlyoutOpen; + set => this.RaiseAndSetIfChanged(ref isShapesFlyoutOpen, value); + } + + private bool isBrushesFlyoutOpen = false; + public bool IsBrushesFlyoutOpen + { + get => isBrushesFlyoutOpen; + set => this.RaiseAndSetIfChanged(ref isBrushesFlyoutOpen, value); + } + + private readonly ObservableAsPropertyHelper isAnyFlyoutOpen; + public bool IsAnyFlyoutOpen => isAnyFlyoutOpen.Value; + + private IDrawingTool lastActiveShapeTool; + public IDrawingTool LastActiveShapeTool + { + get => lastActiveShapeTool; + set => this.RaiseAndSetIfChanged(ref lastActiveShapeTool, value); + } + + public ToolbarViewModel( + ILayerFacade layerFacade, + SelectionViewModel selectionVM, + HistoryViewModel historyVM, + IMessageBus messageBus, + IBitmapCache bitmapCacheManager, + NavigationModel navigationModel, + IFileSaver fileSaver, + IPreferencesFacade preferencesFacade) + { + this.layerFacade = layerFacade; + this.selectionVM = selectionVM; + this.historyVM = historyVM; + this.messageBus = messageBus; + this.bitmapCacheManager = bitmapCacheManager; + this.navigationModel = navigationModel; + this.fileSaver = fileSaver; + + // Listen for ViewOptions changes + this.messageBus.Listen().Subscribe(msg => + { + ShowButtonLabels = msg.ShowButtonLabels; + }); + + // Initialize Tools and Shapes + AvailableTools = + [ + new SelectTool(messageBus), + new FreehandTool(messageBus), + new RectangleTool(messageBus), + new EllipseTool(messageBus), + new LineTool(messageBus), + new FillTool(messageBus), + new EraserBrushTool(messageBus, preferencesFacade) + ]; + + AvailableBrushShapes = + [ + BrushShape.Circle(), + BrushShape.Square(), + BrushShape.Star(), + BrushShape.Heart(), + BrushShape.Sparkle(), + BrushShape.Cloud(), + BrushShape.Moon(), + BrushShape.Lightning(), + BrushShape.Diamond(), + BrushShape.Triangle(), + BrushShape.Hexagon(), + BrushShape.Unicorn(), + BrushShape.Giraffe(), + BrushShape.Bear(), + BrushShape.Elephant(), + BrushShape.Tiger(), + BrushShape.Monkey(), + BrushShape.Fireworks(), + BrushShape.Flower(), + BrushShape.Sun(), + BrushShape.Snowflake(), + BrushShape.Butterfly(), + BrushShape.Fish(), + BrushShape.Paw(), + BrushShape.Leaf(), + BrushShape.MusicNote(), + BrushShape.Smile() + ]; + + activeTool = new FreehandTool(messageBus); + currentBrushShape = AvailableBrushShapes.First(); + + // Initialize commands + SelectToolCommand = ReactiveCommand.Create(tool => + { + ActiveTool = tool; + }, outputScheduler: RxApp.MainThreadScheduler); - private byte opacity = 255; - public virtual byte Opacity - { - get => opacity; - set => this.RaiseAndSetIfChanged(ref opacity, value); - } + isAnyFlyoutOpen = this.WhenAnyValue(x => x.IsSettingsOpen, x => x.IsShapesFlyoutOpen, x => x.IsBrushesFlyoutOpen) + .Select(values => values.Item1 || values.Item2 || values.Item3) + .ToProperty(this, x => x.IsAnyFlyoutOpen); - private byte flow = 255; - public virtual byte Flow + // Reactive Logic: Close flyouts when ActiveTool changes + this.WhenAnyValue(x => x.ActiveTool) + .Skip(1) // Don't trigger on initialization + .Subscribe(_ => { - get => flow; - set => this.RaiseAndSetIfChanged(ref flow, value); - } + IsBrushesFlyoutOpen = false; + IsShapesFlyoutOpen = false; + IsSettingsOpen = false; + }); - private float spacing = 1f; - public virtual float Spacing - { - get => spacing; - set => this.RaiseAndSetIfChanged(ref spacing, value); - } + // Listen for messages that update tool state + this.messageBus.Listen().Subscribe(msg => + { + if (msg.StrokeColor.HasValue) StrokeColor = msg.StrokeColor.Value; + if (msg.ShouldClearFillColor) FillColor = null; + else if (msg.FillColor.HasValue) FillColor = msg.FillColor.Value; + if (msg.Transparency.HasValue) Opacity = msg.Transparency.Value; + if (msg.Flow.HasValue) Flow = msg.Flow.Value; + if (msg.Spacing.HasValue) Spacing = msg.Spacing.Value; + if (msg.StrokeWidth.HasValue) StrokeWidth = msg.StrokeWidth.Value; + if (msg.IsGlowEnabled.HasValue) IsGlowEnabled = msg.IsGlowEnabled.Value; + if (msg.GlowColor.HasValue) GlowColor = msg.GlowColor.Value; + if (msg.GlowRadius.HasValue) GlowRadius = msg.GlowRadius.Value; + if (msg.IsRainbowEnabled.HasValue) IsRainbowEnabled = msg.IsRainbowEnabled.Value; + if (msg.ScatterRadius.HasValue) ScatterRadius = msg.ScatterRadius.Value; + if (msg.SizeJitter.HasValue) SizeJitter = msg.SizeJitter.Value; + if (msg.AngleJitter.HasValue) AngleJitter = msg.AngleJitter.Value; + if (msg.HueJitter.HasValue) HueJitter = msg.HueJitter.Value; + }); + + this.messageBus.Listen().Subscribe(msg => + { + CurrentBrushShape = msg.Shape; + }); - private BrushShape currentBrushShape; - public virtual BrushShape CurrentBrushShape - { - get => currentBrushShape; - set => this.RaiseAndSetIfChanged(ref currentBrushShape, value); - } + lastActiveShapeTool = AvailableTools.FirstOrDefault(t => t is RectangleTool) + ?? AvailableTools.FirstOrDefault(t => t is EllipseTool) + ?? AvailableTools.FirstOrDefault(t => t is LineTool) + ?? new RectangleTool(messageBus); - private bool isGlowEnabled = false; - public virtual bool IsGlowEnabled - { - get => isGlowEnabled; - set => this.RaiseAndSetIfChanged(ref isGlowEnabled, value); - } + ShowShapesFlyoutCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = false; + IsBrushesFlyoutOpen = false; + + if (ActiveTool == LastActiveShapeTool) + { + IsShapesFlyoutOpen = !IsShapesFlyoutOpen; + } + else + { + SelectToolCommand.Execute(LastActiveShapeTool).Subscribe(); + IsShapesFlyoutOpen = false; + } + }); + + ShowBrushesFlyoutCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = false; + IsShapesFlyoutOpen = false; + + var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); + + if (ActiveTool == freehandTool) + { + IsBrushesFlyoutOpen = !IsBrushesFlyoutOpen; + } + else + { + if (freehandTool != null) + SelectToolCommand.Execute(freehandTool).Subscribe(); + IsBrushesFlyoutOpen = false; + } + }); + + SelectBrushShapeCommand = ReactiveCommand.Create(shape => + { + this.messageBus.SendMessage(new BrushShapeChangedMessage(shape)); + IsBrushesFlyoutOpen = false; - private SKColor glowColor = SKColors.Yellow; - public virtual SKColor GlowColor - { - get => glowColor; - set => this.RaiseAndSetIfChanged(ref glowColor, value); - } + var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); + if (freehandTool != null && ActiveTool != freehandTool) + { + SelectToolCommand.Execute(freehandTool).Subscribe(); + } + }); - private float glowRadius = 10f; - public virtual float GlowRadius - { - get => glowRadius; - set => this.RaiseAndSetIfChanged(ref glowRadius, value); - } + ShowSettingsCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = !IsSettingsOpen; + IsShapesFlyoutOpen = false; + IsBrushesFlyoutOpen = false; + }); - private bool isRainbowEnabled; - public virtual bool IsRainbowEnabled - { - get => isRainbowEnabled; - set => this.RaiseAndSetIfChanged(ref isRainbowEnabled, value); - } + ShowAdvancedSettingsCommand = ReactiveCommand.Create(() => + { + messageBus.SendMessage(new ShowAdvancedSettingsMessage()); + }); - private float scatterRadius; - public virtual float ScatterRadius - { - get => scatterRadius; - set => this.RaiseAndSetIfChanged(ref scatterRadius, value); - } + SelectRectangleCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is RectangleTool) ?? new RectangleTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float sizeJitter; - public virtual float SizeJitter - { - get => sizeJitter; - set => this.RaiseAndSetIfChanged(ref sizeJitter, value); - } + SelectCircleCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is EllipseTool) ?? new EllipseTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float angleJitter; - public virtual float AngleJitter - { - get => angleJitter; - set => this.RaiseAndSetIfChanged(ref angleJitter, value); - } + SelectLineCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is LineTool) ?? new LineTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float hueJitter; - public virtual float HueJitter + ImportImageCommand = ReactiveCommand.CreateFromTask(async () => + { + try + { + var result = await FilePicker.Default.PickAsync(new PickOptions { - get => hueJitter; - set => this.RaiseAndSetIfChanged(ref hueJitter, value); - } + PickerTitle = "Select an image to import", + FileTypes = FilePickerFileType.Images + }); - public List AvailableTools { get; } - public List AvailableBrushShapes { get; } - - // Delegated Commands - public ReactiveCommand SelectToolCommand { get; } - public ReactiveCommand UndoCommand => historyVM.UndoCommand; - public ReactiveCommand RedoCommand => historyVM.RedoCommand; - public ReactiveCommand CopyCommand => selectionVM.CopyCommand; - public ReactiveCommand PasteCommand => selectionVM.PasteCommand; - public ReactiveCommand DeleteSelectedCommand => selectionVM.DeleteSelectedCommand; - public ReactiveCommand GroupSelectedCommand => selectionVM.GroupSelectedCommand; - public ReactiveCommand UngroupSelectedCommand => selectionVM.UngroupSelectedCommand; - - // Local Commands - public ReactiveCommand ShowSettingsCommand { get; } - public ReactiveCommand ShowShapesFlyoutCommand { get; } - public ReactiveCommand SelectRectangleCommand { get; } - public ReactiveCommand SelectCircleCommand { get; } - public ReactiveCommand SelectLineCommand { get; } - public ReactiveCommand ShowBrushesFlyoutCommand { get; } - public ReactiveCommand SelectBrushShapeCommand { get; } - public ReactiveCommand ImportImageCommand { get; } - public ReactiveCommand SaveImageCommand { get; } - - // UI state properties - private bool isSettingsOpen = false; - public bool IsSettingsOpen + if (result != null) { - get => isSettingsOpen; - set => this.RaiseAndSetIfChanged(ref isSettingsOpen, value); - } + string path = result.FullPath; + + // On platforms where FullPath is not available, copy to cache + if (string.IsNullOrEmpty(path)) + { + path = Path.Combine(FileSystem.CacheDirectory, result.FileName); + using var sourceStream = await result.OpenReadAsync(); + using var destStream = File.Create(path); + await sourceStream.CopyToAsync(destStream); + } + + // Load with downsampling (max 2048x2048) + var bitmap = await this.bitmapCacheManager.GetBitmapAsync(path, 2048, 2048); + + if (bitmap != null) + { + var drawableImage = new DrawableImage(bitmap) + { + SourcePath = path + }; - private bool isShapesFlyoutOpen = false; - public bool IsShapesFlyoutOpen - { - get => isShapesFlyoutOpen; - set => this.RaiseAndSetIfChanged(ref isShapesFlyoutOpen, value); + this.layerFacade.CurrentLayer?.Elements.Add(drawableImage); + this.messageBus.SendMessage(new CanvasInvalidateMessage()); + this.layerFacade.SaveState(); + } } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error importing image: {ex.Message}"); + } + }); + + SaveImageCommand = ReactiveCommand.CreateFromTask(async () => + { + try + { + if (this.navigationModel.CanvasWidth <= 0 || this.navigationModel.CanvasHeight <= 0) + return; - private bool isBrushesFlyoutOpen = false; - public bool IsBrushesFlyoutOpen - { - get => isBrushesFlyoutOpen; - set => this.RaiseAndSetIfChanged(ref isBrushesFlyoutOpen, value); - } + using var surface = SKSurface.Create(new SKImageInfo((int)this.navigationModel.CanvasWidth, (int)this.navigationModel.CanvasHeight)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); - private readonly ObservableAsPropertyHelper isAnyFlyoutOpen; - public bool IsAnyFlyoutOpen => isAnyFlyoutOpen.Value; + canvas.Save(); - private IDrawingTool lastActiveShapeTool; - public IDrawingTool LastActiveShapeTool - { - get => lastActiveShapeTool; - set => this.RaiseAndSetIfChanged(ref lastActiveShapeTool, value); - } + // Apply the view transformation matrix + canvas.SetMatrix(this.navigationModel.ViewMatrix); - public ToolbarViewModel( - ILayerFacade layerFacade, - SelectionViewModel selectionVM, - HistoryViewModel historyVM, - IMessageBus messageBus, - IBitmapCache bitmapCacheManager, - NavigationModel navigationModel, - IFileSaver fileSaver) + // Draw layers with masking support + var layers = this.layerFacade.Layers; + for (int i = 0; i < layers.Count; i++) { - this.layerFacade = layerFacade; - this.selectionVM = selectionVM; - this.historyVM = historyVM; - this.messageBus = messageBus; - this.bitmapCacheManager = bitmapCacheManager; - this.navigationModel = navigationModel; - this.fileSaver = fileSaver; - - // Initialize Tools and Shapes - AvailableTools = - [ - new SelectTool(messageBus), - new FreehandTool(messageBus), - new RectangleTool(messageBus), - new EllipseTool(messageBus), - new LineTool(messageBus), - new FillTool(messageBus), - new EraserBrushTool(messageBus) - ]; - - AvailableBrushShapes = - [ - BrushShape.Circle(), - BrushShape.Square(), - BrushShape.Star(), - BrushShape.Heart(), - BrushShape.Sparkle(), - BrushShape.Cloud(), - BrushShape.Moon(), - BrushShape.Lightning(), - BrushShape.Diamond(), - BrushShape.Triangle(), - BrushShape.Hexagon() - ]; - - activeTool = new FreehandTool(messageBus); - currentBrushShape = AvailableBrushShapes.First(); - - // Initialize commands - SelectToolCommand = ReactiveCommand.Create(tool => - { - ActiveTool = tool; - }, outputScheduler: RxApp.MainThreadScheduler); - - isAnyFlyoutOpen = this.WhenAnyValue(x => x.IsSettingsOpen, x => x.IsShapesFlyoutOpen, x => x.IsBrushesFlyoutOpen) - .Select(values => values.Item1 || values.Item2 || values.Item3) - .ToProperty(this, x => x.IsAnyFlyoutOpen); - - // Reactive Logic: Close flyouts when ActiveTool changes - this.WhenAnyValue(x => x.ActiveTool) - .Skip(1) // Don't trigger on initialization - .Subscribe(_ => - { - IsBrushesFlyoutOpen = false; - IsShapesFlyoutOpen = false; - IsSettingsOpen = false; - }); - - // Listen for messages that update tool state - this.messageBus.Listen().Subscribe(msg => - { - if (msg.StrokeColor.HasValue) StrokeColor = msg.StrokeColor.Value; - if (msg.ShouldClearFillColor) FillColor = null; - else if (msg.FillColor.HasValue) FillColor = msg.FillColor.Value; - if (msg.Transparency.HasValue) Opacity = msg.Transparency.Value; - if (msg.Flow.HasValue) Flow = msg.Flow.Value; - if (msg.Spacing.HasValue) Spacing = msg.Spacing.Value; - if (msg.StrokeWidth.HasValue) StrokeWidth = msg.StrokeWidth.Value; - if (msg.IsGlowEnabled.HasValue) IsGlowEnabled = msg.IsGlowEnabled.Value; - if (msg.GlowColor.HasValue) GlowColor = msg.GlowColor.Value; - if (msg.GlowRadius.HasValue) GlowRadius = msg.GlowRadius.Value; - if (msg.IsRainbowEnabled.HasValue) IsRainbowEnabled = msg.IsRainbowEnabled.Value; - if (msg.ScatterRadius.HasValue) ScatterRadius = msg.ScatterRadius.Value; - if (msg.SizeJitter.HasValue) SizeJitter = msg.SizeJitter.Value; - if (msg.AngleJitter.HasValue) AngleJitter = msg.AngleJitter.Value; - if (msg.HueJitter.HasValue) HueJitter = msg.HueJitter.Value; - }); - - this.messageBus.Listen().Subscribe(msg => - { - CurrentBrushShape = msg.Shape; - }); - - lastActiveShapeTool = AvailableTools.FirstOrDefault(t => t is RectangleTool) - ?? AvailableTools.FirstOrDefault(t => t is EllipseTool) - ?? AvailableTools.FirstOrDefault(t => t is LineTool) - ?? new RectangleTool(messageBus); - - ShowShapesFlyoutCommand = ReactiveCommand.Create(() => + var layer = layers[i]; + if (!layer.IsVisible) continue; + + if (layer.MaskingMode == Logic.Models.MaskingMode.Clip) + { + layer.Draw(canvas); + } + else + { + // Check if next layers are clipping layers + bool hasClippingLayers = false; + int nextIndex = i + 1; + while (nextIndex < layers.Count && layers[nextIndex].MaskingMode == Logic.Models.MaskingMode.Clip) { - IsSettingsOpen = false; - IsBrushesFlyoutOpen = false; - - if (ActiveTool == LastActiveShapeTool) - { - IsShapesFlyoutOpen = !IsShapesFlyoutOpen; - } - else - { - SelectToolCommand.Execute(LastActiveShapeTool).Subscribe(); - IsShapesFlyoutOpen = false; - } - }); - - ShowBrushesFlyoutCommand = ReactiveCommand.Create(() => - { - IsSettingsOpen = false; - IsShapesFlyoutOpen = false; - - var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); - - if (ActiveTool == freehandTool) - { - IsBrushesFlyoutOpen = !IsBrushesFlyoutOpen; - } - else - { - if (freehandTool != null) - SelectToolCommand.Execute(freehandTool).Subscribe(); - IsBrushesFlyoutOpen = false; - } - }); + if (layers[nextIndex].IsVisible) hasClippingLayers = true; + nextIndex++; + } - SelectBrushShapeCommand = ReactiveCommand.Create(shape => + if (hasClippingLayers) { - this.messageBus.SendMessage(new LunaDraw.Logic.Messages.BrushShapeChangedMessage(shape)); - IsBrushesFlyoutOpen = false; + canvas.SaveLayer(); + layer.Draw(canvas); - var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); - if (freehandTool != null && ActiveTool != freehandTool) + using (var paint = new SKPaint { BlendMode = SKBlendMode.SrcATop }) + { + for (int j = i + 1; j < layers.Count; j++) { - SelectToolCommand.Execute(freehandTool).Subscribe(); - } - }); - - ShowSettingsCommand = ReactiveCommand.Create(() => - { - IsSettingsOpen = !IsSettingsOpen; - IsShapesFlyoutOpen = false; - IsBrushesFlyoutOpen = false; - }); + var clipLayer = layers[j]; + if (clipLayer.MaskingMode != Logic.Models.MaskingMode.Clip) break; - SelectRectangleCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is RectangleTool) ?? new RectangleTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); - - SelectCircleCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is EllipseTool) ?? new EllipseTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); - - SelectLineCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is LineTool) ?? new LineTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); + if (clipLayer.IsVisible) + { + canvas.SaveLayer(paint); + clipLayer.Draw(canvas); + canvas.Restore(); + } - ImportImageCommand = ReactiveCommand.CreateFromTask(async () => - { - try - { - var result = await FilePicker.Default.PickAsync(new PickOptions - { - PickerTitle = "Select an image to import", - FileTypes = FilePickerFileType.Images - }); - - if (result != null) - { - string path = result.FullPath; - - // On platforms where FullPath is not available, copy to cache - if (string.IsNullOrEmpty(path)) - { - path = Path.Combine(FileSystem.CacheDirectory, result.FileName); - using var sourceStream = await result.OpenReadAsync(); - using var destStream = File.Create(path); - await sourceStream.CopyToAsync(destStream); - } - - // Load with downsampling (max 2048x2048) - var bitmap = await this.bitmapCacheManager.GetBitmapAsync(path, 2048, 2048); - if (bitmap != null) - { - var drawableImage = new DrawableImage(bitmap) - { - SourcePath = path - }; - - this.layerFacade.CurrentLayer?.Elements.Add(drawableImage); - this.messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerFacade.SaveState(); - } - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error importing image: {ex.Message}"); + i = j; } - }); + } - SaveImageCommand = ReactiveCommand.CreateFromTask(async () => + canvas.Restore(); + } + else { - try - { - if (this.navigationModel.CanvasWidth <= 0 || this.navigationModel.CanvasHeight <= 0) - return; - - using var surface = SKSurface.Create(new SKImageInfo((int)this.navigationModel.CanvasWidth, (int)this.navigationModel.CanvasHeight)); - var canvas = surface.Canvas; - canvas.Clear(SKColors.White); - - canvas.Save(); - - // Apply the view transformation matrix - canvas.SetMatrix(this.navigationModel.ViewMatrix); - - // Draw layers with masking support - var layers = this.layerFacade.Layers; - for (int i = 0; i < layers.Count; i++) - { - var layer = layers[i]; - if (!layer.IsVisible) continue; - - if (layer.MaskingMode == Logic.Models.MaskingMode.Clip) - { - layer.Draw(canvas); - } - else - { - // Check if next layers are clipping layers - bool hasClippingLayers = false; - int nextIndex = i + 1; - while (nextIndex < layers.Count && layers[nextIndex].MaskingMode == Logic.Models.MaskingMode.Clip) - { - if (layers[nextIndex].IsVisible) hasClippingLayers = true; - nextIndex++; - } - - if (hasClippingLayers) - { - canvas.SaveLayer(); - layer.Draw(canvas); - - using (var paint = new SKPaint { BlendMode = SKBlendMode.SrcATop }) - { - for (int j = i + 1; j < layers.Count; j++) - { - var clipLayer = layers[j]; - if (clipLayer.MaskingMode != Logic.Models.MaskingMode.Clip) break; - - if (clipLayer.IsVisible) - { - canvas.SaveLayer(paint); - clipLayer.Draw(canvas); - canvas.Restore(); - } - - i = j; - } - } - - canvas.Restore(); - } - else - { - layer.Draw(canvas); - } - } - } - - canvas.Restore(); - - using var image = surface.Snapshot(); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - using var stream = data.AsStream(); - - var result = await this.fileSaver.SaveAsync("lunadraw_canvas.png", stream); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error saving image: {ex.Message}"); - } - }); + layer.Draw(canvas); + } + } } - } -} + + canvas.Restore(); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var stream = data.AsStream(); + + var result = await this.fileSaver.SaveAsync("lunadraw_canvas.png", stream); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error saving image: {ex.Message}"); + } + }); + } +} \ No newline at end of file diff --git a/LunaDraw.csproj b/LunaDraw.csproj index 7e441cc..2c509b4 100644 --- a/LunaDraw.csproj +++ b/LunaDraw.csproj @@ -41,8 +41,12 @@ 10.0.17763.0 10.0.17763.0 6.5 - $(DefaultItemExcludes);legacy\** $(DefaultItemExcludes);Tests\** + true + + + + false @@ -65,13 +69,14 @@ - + - - - - + + + + + diff --git a/MauiProgram.cs b/MauiProgram.cs index b36ae70..668729d 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -23,9 +23,9 @@ using CommunityToolkit.Maui; using CommunityToolkit.Maui.Storage; -using LunaDraw.Logic.Managers; +using Microsoft.Maui.LifecycleEvents; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Models; -using LunaDraw.Logic.Services; using LunaDraw.Logic.ViewModels; using LunaDraw.Pages; using Microsoft.Extensions.Logging; @@ -34,6 +34,10 @@ using SkiaSharp.Views.Maui.Controls.Hosting; using Splat; +#if WINDOWS +using Microsoft.UI.Xaml.Media; +#endif + namespace LunaDraw; public static class MauiProgram @@ -54,10 +58,26 @@ public static MauiApp CreateMauiApp() { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }) + .ConfigureLifecycleEvents(events => + { +#if WINDOWS + events.AddWindows(wndLifeCycleBuilder => + { + wndLifeCycleBuilder.OnWindowCreated(window => + { + window.SystemBackdrop = new DesktopAcrylicBackdrop(); + if (Preferences.Get(AppPreference.IsTransparentBackgroundEnabled.ToString(), false)) + { + PlatformHelper.EnableTrueTransparency(180); // Fully transparent + } + }); + }); +#endif }); // Register Core State Managers - builder.Services.AddSingleton(new ReactiveUI.MessageBus()); + builder.Services.AddSingleton(new MessageBus()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -65,7 +85,8 @@ public static MauiApp CreateMauiApp() // Register Logic Services builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(FileSaver.Default); // Register ViewModels diff --git a/Pages/MainPage.xaml b/Pages/MainPage.xaml index decaa7b..a36039a 100644 --- a/Pages/MainPage.xaml +++ b/Pages/MainPage.xaml @@ -28,54 +28,77 @@ Touch="OnTouch" IgnorePixelScaling="False"> - + + BindingContext="{Binding ToolbarViewModel}"/> + BindingContext="{Binding ToolbarViewModel}"/> + BindingContext="{Binding ToolbarViewModel}"/> + + + + + +