From c77d51fb58ea742c50bd224aceb34a1de6d8d6b1 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 21 Oct 2023 20:19:15 +0200 Subject: [PATCH] Workshop - Layout submission WIP --- .../Models/Surface/Layout/ArtemisLedLayout.cs | 10 ++ .../Controls/DeviceVisualizer.cs | 46 ++---- .../Controls/DeviceVisualizerLed.cs | 24 +-- .../Extensions/ArtemisLayoutExtensions.cs | 139 ++++++++++++++++++ .../Layout/LayoutSelectionStepView.axaml | 68 +++++++++ .../Layout/LayoutSelectionStepView.axaml.cs | 14 ++ .../Layout/LayoutSelectionStepViewModel.cs | 116 +++++++++++++++ .../Steps/SpecificationsStepViewModel.cs | 2 + .../SubmissionWizard/SubmissionWizardState.cs | 3 + 9 files changed, 364 insertions(+), 58 deletions(-) create mode 100644 src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs diff --git a/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs b/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs index 29872c4b6..18fc2490f 100644 --- a/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs +++ b/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs @@ -15,6 +15,11 @@ public class ArtemisLedLayout DeviceLayout = deviceLayout; RgbLayout = led; LayoutCustomLedData = (LayoutCustomLedData?) led.CustomData ?? new LayoutCustomLedData(); + + // Default to the first logical layout for images + LayoutCustomLedDataLogicalLayout? defaultLogicalLayout = LayoutCustomLedData.LogicalLayouts?.FirstOrDefault(); + if (defaultLogicalLayout != null) + ApplyLogicalLayout(defaultLogicalLayout); } /// @@ -54,6 +59,11 @@ public class ArtemisLedLayout .ThenBy(l => l.Name == null) .First(); + ApplyLogicalLayout(logicalLayout); + } + + private void ApplyLogicalLayout(LayoutCustomLedDataLogicalLayout logicalLayout) + { LogicalName = logicalLayout.Name; Image = new Uri(Path.Combine(Path.GetDirectoryName(DeviceLayout.FilePath)!, logicalLayout.Image!), UriKind.Absolute); } diff --git a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs index c21f91d58..9eae5804d 100644 --- a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs +++ b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.IO; using System.Linq; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Shared.Events; +using Artemis.UI.Shared.Extensions; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -36,7 +35,7 @@ public class DeviceVisualizer : Control private ArtemisDevice? _oldDevice; private bool _loading; private Color[] _previousState = Array.Empty(); - + /// public DeviceVisualizer() { @@ -69,11 +68,7 @@ public class DeviceVisualizer : Control // Render device and LED images if (_deviceImage != null) - drawingContext.DrawImage( - _deviceImage, - new Rect(_deviceImage.Size), - new Rect(0, 0, Device.RgbDevice.ActualSize.Width, Device.RgbDevice.ActualSize.Height) - ); + drawingContext.DrawImage(_deviceImage, new Rect(_deviceImage.Size), new Rect(0, 0, Device.RgbDevice.ActualSize.Width, Device.RgbDevice.ActualSize.Height)); if (!ShowColors) return; @@ -163,7 +158,7 @@ public class DeviceVisualizer : Control { if (Device == null || float.IsNaN(Device.RgbDevice.ActualSize.Width) || float.IsNaN(Device.RgbDevice.ActualSize.Height)) return new Rect(); - + Rect deviceRect = new(0, 0, Device.RgbDevice.ActualSize.Width, Device.RgbDevice.ActualSize.Height); Geometry geometry = new RectangleGeometry(deviceRect); geometry.Transform = new RotateTransform(Device.Rotation); @@ -316,34 +311,15 @@ public class DeviceVisualizer : Control private RenderTargetBitmap? GetDeviceImage(ArtemisDevice device) { - string? path = device.Layout?.Image?.LocalPath; - if (path == null) + ArtemisLayout? layout = device.Layout; + if (layout == null) return null; - - if (BitmapCache.TryGetValue(path, out RenderTargetBitmap? existingBitmap)) + + if (BitmapCache.TryGetValue(layout.FilePath, out RenderTargetBitmap? existingBitmap)) return existingBitmap; - if (!File.Exists(path)) - { - BitmapCache[path] = null; - return null; - } - - // Create a bitmap that'll be used to render the device and LED images just once - // Render 4 times the actual size of the device to make sure things look sharp when zoomed in - RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) device.RgbDevice.ActualSize.Width * 2, (int) device.RgbDevice.ActualSize.Height * 2)); - - using DrawingContext context = renderTargetBitmap.CreateDrawingContext(); - using Bitmap bitmap = new(path); - using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(renderTargetBitmap.PixelSize); - - context.DrawImage(scaledBitmap, new Rect(scaledBitmap.Size)); - lock (_deviceVisualizerLeds) - { - foreach (DeviceVisualizerLed deviceVisualizerLed in _deviceVisualizerLeds) - deviceVisualizerLed.DrawBitmap(context, 2 * device.Scale); - } - - // BitmapCache[path] = renderTargetBitmap; + + RenderTargetBitmap renderTargetBitmap = layout.RenderLayout(false); + BitmapCache[layout.FilePath] = renderTargetBitmap; return renderTargetBitmap; } diff --git a/src/Artemis.UI.Shared/Controls/DeviceVisualizerLed.cs b/src/Artemis.UI.Shared/Controls/DeviceVisualizerLed.cs index 576dd836d..0001c17f8 100644 --- a/src/Artemis.UI.Shared/Controls/DeviceVisualizerLed.cs +++ b/src/Artemis.UI.Shared/Controls/DeviceVisualizerLed.cs @@ -1,9 +1,7 @@ using System; -using System.IO; using Artemis.Core; using Avalonia; using Avalonia.Media; -using Avalonia.Media.Imaging; using RGB.NET.Core; using Color = Avalonia.Media.Color; using Point = Avalonia.Point; @@ -30,27 +28,7 @@ internal class DeviceVisualizerLed public ArtemisLed Led { get; } public Geometry? DisplayGeometry { get; private set; } - - public void DrawBitmap(DrawingContext drawingContext, double scale) - { - if (Led.Layout?.Image == null || !File.Exists(Led.Layout.Image.LocalPath)) - return; - - try - { - using Bitmap bitmap = new(Led.Layout.Image.LocalPath); - using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(new PixelSize((Led.RgbLed.Size.Width * scale).RoundToInt(), (Led.RgbLed.Size.Height * scale).RoundToInt())); - drawingContext.DrawImage( - scaledBitmap, - new Rect(Led.RgbLed.Location.X * scale, Led.RgbLed.Location.Y * scale, scaledBitmap.Size.Width, scaledBitmap.Size.Height) - ); - } - catch - { - // ignored - } - } - + public void RenderGeometry(DrawingContext drawingContext) { if (DisplayGeometry == null) diff --git a/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs b/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs new file mode 100644 index 000000000..7e0f3853a --- /dev/null +++ b/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using Artemis.Core; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using RGB.NET.Core; +using Color = Avalonia.Media.Color; +using SolidColorBrush = Avalonia.Media.SolidColorBrush; + +namespace Artemis.UI.Shared.Extensions; + +/// +/// Provides extension methods for the type. +/// +public static class ArtemisLayoutExtensions +{ + /// + /// Renders the layout to a bitmap. + /// + /// The layout to render + /// The resulting bitmap. + public static RenderTargetBitmap RenderLayout(this ArtemisLayout layout, bool previewLeds) + { + string? path = layout.Image?.LocalPath; + + // Create a bitmap that'll be used to render the device and LED images just once + // Render 4 times the actual size of the device to make sure things look sharp when zoomed in + RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) layout.RgbLayout.Width * 2, (int) layout.RgbLayout.Height * 2)); + + using DrawingContext context = renderTargetBitmap.CreateDrawingContext(); + + // Draw device background + if (path != null && File.Exists(path)) + { + using Bitmap bitmap = new(path); + using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(renderTargetBitmap.PixelSize); + context.DrawImage(scaledBitmap, new Rect(scaledBitmap.Size)); + } + + // Draw LED images + foreach (ArtemisLedLayout led in layout.Leds) + { + string? ledPath = led.Image?.LocalPath; + if (ledPath == null || !File.Exists(ledPath)) + continue; + using Bitmap bitmap = new(ledPath); + using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(new PixelSize((led.RgbLayout.Width * 2).RoundToInt(), (led.RgbLayout.Height * 2).RoundToInt())); + context.DrawImage(scaledBitmap, new Rect(led.RgbLayout.X * 2, led.RgbLayout.Y * 2, scaledBitmap.Size.Width, scaledBitmap.Size.Height)); + } + + if (!previewLeds) + return renderTargetBitmap; + + // Draw LED geometry using a rainbow gradient + ColorGradient colors = ColorGradient.GetUnicornBarf(); + colors.ToggleSeamless(); + context.PushTransform(Matrix.CreateScale(2, 2)); + foreach (ArtemisLedLayout led in layout.Leds) + { + Geometry? geometry = CreateLedGeometry(led); + if (geometry == null) + continue; + + Color color = colors.GetColor((led.RgbLayout.X + led.RgbLayout.Width / 2) / layout.RgbLayout.Width).ToColor(); + SolidColorBrush fillBrush = new() {Color = color, Opacity = 0.4}; + SolidColorBrush penBrush = new() {Color = color}; + Pen pen = new(penBrush) {LineJoin = PenLineJoin.Round}; + context.DrawGeometry(fillBrush, pen, geometry); + } + + return renderTargetBitmap; + } + + private static Geometry? CreateLedGeometry(ArtemisLedLayout led) + { + // The minimum required size for geometry to be created + if (led.RgbLayout.Width < 2 || led.RgbLayout.Height < 2) + return null; + + switch (led.RgbLayout.Shape) + { + case Shape.Custom: + if (led.DeviceLayout.RgbLayout.Type is RGBDeviceType.Keyboard or RGBDeviceType.Keypad) + return CreateCustomGeometry(led, 2.0); + return CreateCustomGeometry(led, 1.0); + case Shape.Rectangle: + if (led.DeviceLayout.RgbLayout.Type is RGBDeviceType.Keyboard or RGBDeviceType.Keypad) + return CreateKeyCapGeometry(led); + return CreateRectangleGeometry(led); + case Shape.Circle: + return CreateCircleGeometry(led); + default: + throw new ArgumentOutOfRangeException(); + } + } + + private static RectangleGeometry CreateRectangleGeometry(ArtemisLedLayout led) + { + return new RectangleGeometry(new Rect(led.RgbLayout.X + 0.5, led.RgbLayout.Y + 0.5, led.RgbLayout.Width - 1, led.RgbLayout.Height - 1)); + } + + private static EllipseGeometry CreateCircleGeometry(ArtemisLedLayout led) + { + return new EllipseGeometry(new Rect(led.RgbLayout.X + 0.5, led.RgbLayout.Y + 0.5, led.RgbLayout.Width - 1, led.RgbLayout.Height - 1)); + } + + private static RectangleGeometry CreateKeyCapGeometry(ArtemisLedLayout led) + { + return new RectangleGeometry(new Rect(led.RgbLayout.X + 1, led.RgbLayout.Y + 1, led.RgbLayout.Width - 2, led.RgbLayout.Height - 2)); + } + + private static Geometry? CreateCustomGeometry(ArtemisLedLayout led, double deflateAmount) + { + try + { + if (led.RgbLayout.ShapeData == null) + return null; + + double width = led.RgbLayout.Width - deflateAmount; + double height = led.RgbLayout.Height - deflateAmount; + + Geometry geometry = Geometry.Parse(led.RgbLayout.ShapeData); + geometry.Transform = new TransformGroup + { + Children = new Transforms + { + new ScaleTransform(width, height), + new TranslateTransform(led.RgbLayout.X + deflateAmount / 2, led.RgbLayout.Y + deflateAmount / 2) + } + }; + return geometry; + } + catch (Exception) + { + return CreateRectangleGeometry(led); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml new file mode 100644 index 000000000..73d78c056 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + Layout selection + + + Please select the layout you want to share by either selecting a device or browsing for the layout file + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml.cs new file mode 100644 index 000000000..a39c14a14 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; + +public partial class LayoutSelectionStepView : ReactiveUserControl +{ + public LayoutSelectionStepView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs new file mode 100644 index 000000000..8250578a1 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs @@ -0,0 +1,116 @@ +using System.Collections.ObjectModel; +using System.Linq; +using Artemis.Core; +using Artemis.Core.Services; +using PropertyChanged.SourceGenerator; +using ReactiveUI; +using System; +using System.IO; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.UI.Extensions; +using Artemis.UI.Shared.Extensions; +using Artemis.UI.Shared.Services; +using Avalonia; +using Avalonia.Media.Imaging; +using Material.Icons; +using RGB.NET.Core; +using SkiaSharp; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; + +public partial class LayoutSelectionStepViewModel : SubmissionViewModel +{ + private readonly IWindowService _windowService; + [Notify] private ArtemisDevice? _selectedDevice; + [Notify] private ArtemisLayout? _layout; + [Notify] private Bitmap? _layoutImage; + + public LayoutSelectionStepViewModel(IDeviceService deviceService, IWindowService windowService) + { + _windowService = windowService; + Devices = new ObservableCollection( + deviceService.Devices + .Where(d => d.Layout != null && d.Layout.IsValid) + .DistinctBy(d => d.Layout?.FilePath) + .OrderBy(d => d.RgbDevice.DeviceInfo.Model) + ); + + Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Layout).Select(p => p != null)); + + this.WhenAnyValue(vm => vm.SelectedDevice).WhereNotNull().Subscribe(d => Layout = d.Layout); + this.WhenAnyValue(vm => vm.Layout).Subscribe(CreatePreviewDevice); + } + + public ObservableCollection Devices { get; } + + public async Task BrowseLayout() + { + string[]? selected = await _windowService.CreateOpenFileDialog().HavingFilter(f => f.WithExtension("xml").WithName("Artemis Layout")).ShowAsync(); + if (selected == null || selected.Length != 1) + return; + + ArtemisLayout layout = new(selected[0], LayoutSource.User); + if (!layout.IsValid) + { + await _windowService.ShowConfirmContentDialog("Invalid layout file", "The selected file does not appear to be a valid RGB.NET layout file", cancel: null); + return; + } + + SelectedDevice = null; + Layout = layout; + } + + private void CreatePreviewDevice(ArtemisLayout? layout) + { + if (layout == null) + { + LayoutImage = null; + return; + } + + LayoutImage = layout.RenderLayout(true); + Layout = layout; + } + + private void ExecuteContinue() + { + if (Layout == null) + return; + + State.EntrySource = Layout; + State.Name = Layout.RgbLayout.Name ?? ""; + State.Summary = !string.IsNullOrWhiteSpace(Layout.RgbLayout.Vendor) + ? $"{Layout.RgbLayout.Vendor} {Layout.RgbLayout.Type} device layout" + : $"{Layout.RgbLayout.Type} device layout"; + + // Go through the hassle of resizing the image to 128x128 without losing aspect ratio, padding is added for this + using RenderTargetBitmap image = Layout.RenderLayout(false); + using MemoryStream stream = new(); + image.Save(stream); + stream.Seek(0, SeekOrigin.Begin); + + MemoryStream output = new(); + using SKBitmap? sourceBitmap = SKBitmap.Decode(stream); + int sourceWidth = sourceBitmap.Width; + int sourceHeight = sourceBitmap.Height; + float scale = Math.Min((float) 128 / sourceWidth, (float) 128 / sourceHeight); + + SKSizeI scaledDimensions = new((int) Math.Floor(sourceWidth * scale), (int) Math.Floor(sourceHeight * scale)); + SKPointI offset = new((128 - scaledDimensions.Width) / 2, (128 - scaledDimensions.Height) / 2); + + using (SKBitmap? scaleBitmap = sourceBitmap.Resize(scaledDimensions, SKFilterQuality.High)) + using (SKBitmap targetBitmap = new(128, 128)) + using (SKCanvas canvas = new(targetBitmap)) + { + canvas.Clear(SKColors.Transparent); + canvas.DrawBitmap(scaleBitmap, offset.X, offset.Y); + targetBitmap.Encode(output, SKEncodedImageFormat.Png, 100); + output.Seek(0, SeekOrigin.Begin); + } + + State.Icon?.Dispose(); + State.Icon = output; + State.ChangeScreen(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index 55e3678a8..fc366c336 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reactive.Disposables; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Entries; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.WebClient.Workshop; using DynamicData; @@ -38,6 +39,7 @@ public partial class SpecificationsStepViewModel : SubmissionViewModel switch (State.EntryType) { case EntryType.Layout: + State.ChangeScreen(); break; case EntryType.Plugin: break; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 893896621..54099f4bc 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; @@ -56,6 +57,8 @@ public class SubmissionWizardState { if (EntryType == EntryType.Profile) ChangeScreen(); + else if (EntryType == EntryType.Layout) + ChangeScreen(); else throw new NotImplementedException(); }