diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f01c568 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- User prefers clean, minimalist code changes and asks to only implement exactly what was requested without extra additions. \ No newline at end of file diff --git a/App.xaml.cs b/App.xaml.cs index da79e32..8e8133b 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,13 +1,17 @@ -using Microsoft.EntityFrameworkCore; +using FluentValidation; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Serilog; using Stack_Solver.Data; using Stack_Solver.Data.Repositories; using Stack_Solver.Infrastructure; using Stack_Solver.Models; +using Stack_Solver.Models.Inputs; using Stack_Solver.Services; +using Stack_Solver.Validation; using Stack_Solver.ViewModels.Pages; using Stack_Solver.ViewModels.Windows; using Stack_Solver.Views.Pages; @@ -31,6 +35,10 @@ public partial class App // https://docs.microsoft.com/dotnet/core/extensions/logging private static readonly IHost _host = Host .CreateDefaultBuilder() + .UseSerilog((context, services, loggerConfiguration) => + { + loggerConfiguration.ReadFrom.Configuration(context.Configuration); + }) .ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory)!); @@ -67,7 +75,9 @@ public partial class App sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetRequiredService>())); + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -76,6 +86,8 @@ public partial class App services.AddSingleton(); services.AddSingleton(); + services.AddValidatorsFromAssemblyContaining(); + services.AddDbContextFactory(options => { options.UseSqlite($"Data Source={AppPaths.DatabaseFile}"); @@ -102,11 +114,15 @@ public static IServiceProvider Services /// private async void OnStartup(object sender, StartupEventArgs e) { + Log.Information("Application starting"); + await _host.StartAsync(); + Log.Information("Host started"); // Initialize database var dbInit = Services.GetRequiredService(); await dbInit.InitializeAsync(); + Log.Information("Database initialized"); } /// @@ -114,17 +130,21 @@ private async void OnStartup(object sender, StartupEventArgs e) /// private async void OnExit(object sender, ExitEventArgs e) { + Log.Information("Application shutting down"); await _host.StopAsync(); + Log.Information("Host stopped"); _host.Dispose(); + Log.CloseAndFlush(); } + /// /// Occurs when an exception is thrown by an application but not handled. /// private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { - // For more info see https://docs.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception?view=windowsdesktop-6.0 + Log.Error(e.Exception, "Unhandled dispatcher exception"); } } } diff --git a/Box.ico b/Box.ico deleted file mode 100644 index 1f54c0b..0000000 Binary files a/Box.ico and /dev/null differ diff --git a/Data/Repositories/SkuRepository.cs b/Data/Repositories/SkuRepository.cs index cf60ef2..b9f2022 100644 --- a/Data/Repositories/SkuRepository.cs +++ b/Data/Repositories/SkuRepository.cs @@ -1,9 +1,10 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Stack_Solver.Models; namespace Stack_Solver.Data.Repositories { - public class SkuRepository(IDbContextFactory factory) : ISkuRepository + public class SkuRepository(IDbContextFactory factory, ILogger logger) : ISkuRepository { public event EventHandler? SkuAdded; public event EventHandler? SkuUpdated; @@ -26,6 +27,10 @@ public async Task AddAsync(SKU sku, CancellationToken ct = default) using var db = await factory.CreateDbContextAsync(ct); db.Skus.Add(sku); await db.SaveChangesAsync(ct); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("SKU added: {SkuId}", sku.SkuId); + } SkuAdded?.Invoke(this, sku); } @@ -34,19 +39,34 @@ public async Task UpdateAsync(SKU sku, CancellationToken ct = default) using var db = await factory.CreateDbContextAsync(ct); db.Skus.Update(sku); await db.SaveChangesAsync(ct); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("SKU updated: {SkuId}", sku.SkuId); + } SkuUpdated?.Invoke(this, sku); } public async Task DeleteAsync(string skuId, CancellationToken ct = default) { using var db = await factory.CreateDbContextAsync(ct); - var entity = await db.Skus.FindAsync(new object?[] { skuId }, ct); + var entity = await db.Skus.FindAsync([skuId], ct); if (entity != null) { db.Skus.Remove(entity); await db.SaveChangesAsync(ct); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("SKU deleted: {SkuId}", skuId); + } SkuDeleted?.Invoke(this, skuId); } + else + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Delete skipped for missing SKU: {SkuId}", skuId); + } + } } public async Task SaveChangesAsync(CancellationToken ct = default) diff --git a/Models/AppConfig.cs b/Models/AppConfig.cs index fb55a52..c3e6084 100644 --- a/Models/AppConfig.cs +++ b/Models/AppConfig.cs @@ -1,9 +1,14 @@ namespace Stack_Solver.Models { + /// + /// Represents the application configuration settings. + /// + /// This class requires the specification of both the configurations folder and the application + /// properties file name to function correctly. public class AppConfig { - public string ConfigurationsFolder { get; set; } + public required string ConfigurationsFolder { get; set; } - public string AppPropertiesFileName { get; set; } + public required string AppPropertiesFileName { get; set; } } } diff --git a/Models/GenerationOptions.cs b/Models/GenerationOptions.cs index c85554c..91e895a 100644 --- a/Models/GenerationOptions.cs +++ b/Models/GenerationOptions.cs @@ -1,5 +1,9 @@ namespace Stack_Solver.Models { + /// + /// Provides configuration options for the generation process. + /// + /// This class stores the generation parameters for easier accessibility. public class GenerationOptions { public int MaxSolverTime { get; set; } diff --git a/Models/Inputs/PalletSettingsDto.cs b/Models/Inputs/PalletSettingsDto.cs new file mode 100644 index 0000000..f15beca --- /dev/null +++ b/Models/Inputs/PalletSettingsDto.cs @@ -0,0 +1,16 @@ +namespace Stack_Solver.Models.Inputs +{ + public class PalletSettingsDto + { + public int PalletLength { get; set; } + public int PalletWidth { get; set; } + public double PalletHeight { get; set; } + public bool UseCpsat { get; set; } + public int MaxCpsatCandidates { get; set; } + public int BlfAttempts { get; set; } + public int SolverTimeLimit { get; set; } + public int MaxStackHeight { get; set; } + public int MaxStackWeight { get; set; } + public double MaxSkuOverhang { get; set; } + } +} diff --git a/Models/Inputs/SkuInputDto.cs b/Models/Inputs/SkuInputDto.cs new file mode 100644 index 0000000..68d8937 --- /dev/null +++ b/Models/Inputs/SkuInputDto.cs @@ -0,0 +1,13 @@ +namespace Stack_Solver.Models.Inputs +{ + public class SkuInputDto + { + public required string Name { get; set; } + public int Length { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public double Weight { get; set; } + public bool Rotatable { get; set; } + public string? Notes { get; set; } + } +} diff --git a/Models/Inputs/SkuQuantityDto.cs b/Models/Inputs/SkuQuantityDto.cs new file mode 100644 index 0000000..3fd2b75 --- /dev/null +++ b/Models/Inputs/SkuQuantityDto.cs @@ -0,0 +1,8 @@ +namespace Stack_Solver.Models.Inputs +{ + public class SkuQuantityDto + { + public required string SkuId { get; set; } + public int Quantity { get; set; } + } +} diff --git a/Models/Layering/Layer.cs b/Models/Layering/Layer.cs index 5a75255..299d3cf 100644 --- a/Models/Layering/Layer.cs +++ b/Models/Layering/Layer.cs @@ -2,6 +2,9 @@ namespace Stack_Solver.Models.Layering { + /// + /// A single layer in a stacking solution, containing positioned items, metadata and geometry information. + /// public class Layer(string name, List items, LayerMetadata metadata) { public string Id { get; set; } = Guid.NewGuid().ToString(); diff --git a/Models/Layering/LayerGeometry.cs b/Models/Layering/LayerGeometry.cs index 40c6428..4473e08 100644 --- a/Models/Layering/LayerGeometry.cs +++ b/Models/Layering/LayerGeometry.cs @@ -1,12 +1,33 @@ namespace Stack_Solver.Models.Layering { + /// + /// Represents the geometric configuration of a layer, including its dimensions, occupancy grid, and item placement + /// information. + /// + /// The number of columns in the layer's grid. Must be a positive integer. + /// The number of rows in the layer's grid. Must be a positive integer. public class LayerGeometry(int width, int length) { - public int Width { get; set; } = width; - public int Length { get; set; } = length; + public int Width { get; } = width; + public int Length { get; } = length; + public int GridStep { get; set; } = 1; - public bool[,] OccupancyGrid { get; set; } = new bool[width, length]; + public bool[,] OccupancyGrid { get; } = new bool[width, length]; + public int[,] ItemIndexGrid { get; } = CreateItemIndexGrid(width, length); - public List ItemRectangles { get; set; } = []; + public List ItemRectangles { get; } = []; + + private static int[,] CreateItemIndexGrid(int width, int length) + { + var grid = new int[width, length]; + for (int y = 0; y < width; y++) + { + for (int x = 0; x < length; x++) + { + grid[y, x] = -1; + } + } + return grid; + } } } diff --git a/Models/Layering/LayerSupportMetrics.cs b/Models/Layering/LayerSupportMetrics.cs new file mode 100644 index 0000000..1bc98fc --- /dev/null +++ b/Models/Layering/LayerSupportMetrics.cs @@ -0,0 +1,7 @@ +namespace Stack_Solver.Models.Layering +{ + /// + /// Summarizes how much of an upper layer lacks direct support from the layer beneath it. + /// + public readonly record struct LayerSupportMetrics(double TotalUnsupportedArea, double MaximumSkuOverhangArea); +} diff --git a/Models/Layering/PositionedItem.cs b/Models/Layering/PositionedItem.cs index 5223ea8..ed6d853 100644 --- a/Models/Layering/PositionedItem.cs +++ b/Models/Layering/PositionedItem.cs @@ -1,5 +1,14 @@ namespace Stack_Solver.Models.Layering { + /// + /// Represents an item placed at a specific position in a 2D layer, defined by its SKU type, + /// coordinates, and whether it's rotated or not. + /// + /// The SKU type. + /// The x-coordinate of the item's position in 2D. + /// The y-coordinate of the item's position in 2D. + /// A value indicating whether the item is rotated. If , the item's length and width are + /// swapped when determining its span. public class PositionedItem(SKU skuType, int x, int y, bool rotated) { public SKU SkuType { get; set; } = skuType; diff --git a/Models/Metadata/LayerMetadata.cs b/Models/Metadata/LayerMetadata.cs index f58aee7..f062fae 100644 --- a/Models/Metadata/LayerMetadata.cs +++ b/Models/Metadata/LayerMetadata.cs @@ -1,5 +1,8 @@ namespace Stack_Solver.Models.Metadata { + /// + /// Metadata about a specific layer in a stacking solution. + /// public class LayerMetadata(double utilization, int height, string description) { public double Utilization { get; set; } = utilization; diff --git a/Models/Metadata/PalletMetadata.cs b/Models/Metadata/PalletMetadata.cs index 38ceae5..8d0efd4 100644 --- a/Models/Metadata/PalletMetadata.cs +++ b/Models/Metadata/PalletMetadata.cs @@ -1,5 +1,8 @@ namespace Stack_Solver.Models.Metadata { + /// + /// Represents metadata for a pallet. + /// public class PalletMetadata { public double TotalWeight { get; set; } diff --git a/Models/PalletDefaultsOptions.cs b/Models/PalletDefaultsOptions.cs index 6c68951..14b10c3 100644 --- a/Models/PalletDefaultsOptions.cs +++ b/Models/PalletDefaultsOptions.cs @@ -1,5 +1,8 @@ namespace Stack_Solver.Models { + /// + /// Represents the default configuration options for pallet properties, including dimensions and weight limits. + /// public class PalletDefaultsOptions { public string? DefaultCatalog { get; set; } @@ -9,5 +12,6 @@ public class PalletDefaultsOptions public double PalletHeight { get; set; } = 14.4; public int MaxStackHeight { get; set; } = 180; public int MaxStackWeight { get; set; } = 950; + public double MaxSkuOverhang { get; set; } = 0; } } diff --git a/Models/SKU.cs b/Models/SKU.cs index 5925353..e24febe 100644 --- a/Models/SKU.cs +++ b/Models/SKU.cs @@ -2,11 +2,15 @@ namespace Stack_Solver.Models { + /// + /// Represents a generic stock keeping unit (SKU) type with properties for identification, physical dimensions, weight, + /// rotatability, etc. + /// public class SKU { [Key] public string SkuId { get; set; } = Guid.NewGuid().ToString(); - public string Name { get; set; } + public required string Name { get; set; } public int Length { get; set; } public int Width { get; set; } public int Height { get; set; } diff --git a/Models/Supports/Pallet.cs b/Models/Supports/Pallet.cs index f71228a..28312e9 100644 --- a/Models/Supports/Pallet.cs +++ b/Models/Supports/Pallet.cs @@ -3,6 +3,13 @@ namespace Stack_Solver.Models.Supports { + /// + /// Represents a pallet that provides a surface for stacking multiple layers of items. + /// + /// The unique identifier for the pallet, used to distinguish it from other pallets. + /// The length of the pallet. + /// The width of the pallet. + /// The height of the pallet. public class Pallet(string name, int length, int width, int height) : SupportSurface(name, length, width, height) { List Layers { get; } = []; diff --git a/Models/Supports/SupportSurface.cs b/Models/Supports/SupportSurface.cs index 6b0a4db..f44bc8f 100644 --- a/Models/Supports/SupportSurface.cs +++ b/Models/Supports/SupportSurface.cs @@ -1,5 +1,12 @@ namespace Stack_Solver.Models.Supports { + /// + /// Represents a generic support surface used for stacking cargo. + /// + /// The name of the support surface. + /// The length of the support surface. + /// The width of the support surface. + /// The height of the support surface (without the cargo). public abstract class SupportSurface(string name, int length, int width, int height) { public string Id { get; set; } = Guid.NewGuid().ToString(); diff --git a/Resources/Fonts/Unbounded-Black.ttf b/Resources/Fonts/Unbounded-Black.ttf new file mode 100644 index 0000000..a82bbf8 Binary files /dev/null and b/Resources/Fonts/Unbounded-Black.ttf differ diff --git a/Resources/Fonts/Unbounded-Bold.ttf b/Resources/Fonts/Unbounded-Bold.ttf new file mode 100644 index 0000000..f872248 Binary files /dev/null and b/Resources/Fonts/Unbounded-Bold.ttf differ diff --git a/Resources/Fonts/Unbounded-ExtraLight.ttf b/Resources/Fonts/Unbounded-ExtraLight.ttf new file mode 100644 index 0000000..7dfc7c7 Binary files /dev/null and b/Resources/Fonts/Unbounded-ExtraLight.ttf differ diff --git a/Resources/Fonts/Unbounded-Light.ttf b/Resources/Fonts/Unbounded-Light.ttf new file mode 100644 index 0000000..10350df Binary files /dev/null and b/Resources/Fonts/Unbounded-Light.ttf differ diff --git a/Resources/Fonts/Unbounded-Medium.ttf b/Resources/Fonts/Unbounded-Medium.ttf new file mode 100644 index 0000000..b46bbaf Binary files /dev/null and b/Resources/Fonts/Unbounded-Medium.ttf differ diff --git a/Resources/Fonts/Unbounded-Regular.ttf b/Resources/Fonts/Unbounded-Regular.ttf new file mode 100644 index 0000000..14a7d8a Binary files /dev/null and b/Resources/Fonts/Unbounded-Regular.ttf differ diff --git a/Services/ApplicationHostService.cs b/Services/ApplicationHostService.cs index a15f2d6..61f420a 100644 --- a/Services/ApplicationHostService.cs +++ b/Services/ApplicationHostService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Stack_Solver.Views.Windows; using Wpf.Ui; @@ -7,16 +8,9 @@ namespace Stack_Solver.Services /// /// Managed host of the application. /// - public class ApplicationHostService : IHostedService + public class ApplicationHostService(IServiceProvider serviceProvider, ILogger logger) : IHostedService { - private readonly IServiceProvider _serviceProvider; - - private INavigationWindow _navigationWindow; - - public ApplicationHostService(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } + private INavigationWindow? _navigationWindow; /// /// Triggered when the application host is ready to start the service. @@ -24,7 +18,9 @@ public ApplicationHostService(IServiceProvider serviceProvider) /// Indicates that the start process has been aborted. public async Task StartAsync(CancellationToken cancellationToken) { + logger.LogInformation("Starting application host service"); await HandleActivationAsync(); + logger.LogInformation("Application host service started"); } /// @@ -33,6 +29,7 @@ public async Task StartAsync(CancellationToken cancellationToken) /// Indicates that the shutdown process should no longer be graceful. public async Task StopAsync(CancellationToken cancellationToken) { + logger.LogInformation("Stopping application host service"); await Task.CompletedTask; } @@ -43,12 +40,14 @@ private async Task HandleActivationAsync() { if (!Application.Current.Windows.OfType().Any()) { + logger.LogDebug("Creating main navigation window"); _navigationWindow = ( - _serviceProvider.GetService(typeof(INavigationWindow)) as INavigationWindow + serviceProvider.GetService(typeof(INavigationWindow)) as INavigationWindow )!; _navigationWindow!.ShowWindow(); _navigationWindow.Navigate(typeof(Views.Pages.DashboardPage)); + logger.LogDebug("Main navigation window shown and dashboard navigated"); } await Task.CompletedTask; diff --git a/Services/DatabaseInitializer.cs b/Services/DatabaseInitializer.cs index b80f609..86712eb 100644 --- a/Services/DatabaseInitializer.cs +++ b/Services/DatabaseInitializer.cs @@ -4,21 +4,19 @@ namespace Stack_Solver.Services { - public class DatabaseInitializer + /// + /// Provides functionality to initialize the application DB. + /// + /// The factory used to create instances of the application's database context. + /// The logger used to record informational messages and errors during database initialization. + public class DatabaseInitializer(IDbContextFactory factory, ILogger logger) { - private readonly IDbContextFactory _factory; - private readonly ILogger _logger; - - public DatabaseInitializer(IDbContextFactory factory, ILogger logger) - { - _factory = factory; - _logger = logger; - } - public async Task InitializeAsync(CancellationToken ct = default) { - await using var db = await _factory.CreateDbContextAsync(ct); + logger.LogInformation("Initializing database"); + await using var db = await factory.CreateDbContextAsync(ct); await db.Database.EnsureCreatedAsync(ct); + logger.LogInformation("Database initialization completed"); } } } \ No newline at end of file diff --git a/Services/ILayerGenerationStrategy.cs b/Services/ILayerGenerationStrategy.cs index cb7dab2..569ad39 100644 --- a/Services/ILayerGenerationStrategy.cs +++ b/Services/ILayerGenerationStrategy.cs @@ -4,6 +4,10 @@ namespace Stack_Solver.Services { + /// + /// Defines a strategy for generating layers based on a collection of SKUs, a support surface, and generation options. + /// + /// Implementations of this interface should provide specific logic for generating layers. public interface ILayerGenerationStrategy { string Name { get; } diff --git a/Services/ILayerStackingStrategy.cs b/Services/ILayerStackingStrategy.cs index 8f7fd0f..0330845 100644 --- a/Services/ILayerStackingStrategy.cs +++ b/Services/ILayerStackingStrategy.cs @@ -4,6 +4,11 @@ namespace Stack_Solver.Services { + /// + /// Defines a strategy for arranging layers on a support surface. + /// + /// Implementations of this interface should provide specific logic for stacking layers according + /// to the supplied options and input layers. public interface ILayerStackingStrategy { string Name { get; } diff --git a/Services/LayerGeometryBuilder.cs b/Services/LayerGeometryBuilder.cs index fba9497..0360f4b 100644 --- a/Services/LayerGeometryBuilder.cs +++ b/Services/LayerGeometryBuilder.cs @@ -3,8 +3,22 @@ namespace Stack_Solver.Services { + /// + /// Provides functionality to construct a LayerGeometry object for a specified layer and support surface, + /// calculating grid dimensions and item placements based on the provided parameters. + /// public static class LayerGeometryBuilder { + /// + /// Builds a LayerGeometry object that represents the spatial arrangement and occupancy grid of items in the + /// specified layer on the given support surface. + /// + /// The layer containing the items to be represented in the geometry. Cannot be null. + /// The support surface that defines the width and length for the geometry calculation. Cannot be null. + /// The size, in units, of each grid cell. Must be a positive integer greater than zero. Defaults to 1. + /// A LayerGeometry object that describes the calculated occupancy grid and item placements for the specified + /// layer and support surface. + /// Thrown if gridStep is less than or equal to zero. public static LayerGeometry Build(Layer layer, SupportSurface supportSurface, int gridStep = 1) { ArgumentNullException.ThrowIfNull(layer); @@ -16,10 +30,14 @@ public static LayerGeometry Build(Layer layer, SupportSurface supportSurface, in int gridWidth = (int)Math.Ceiling((double)supportSurface.Width / gridStep); int gridLength = (int)Math.Ceiling((double)supportSurface.Length / gridStep); - var geometry = new LayerGeometry(gridWidth, gridLength); + var geometry = new LayerGeometry(gridWidth, gridLength) + { + GridStep = gridStep + }; - foreach (var item in layer.Items) + for (int itemIndex = 0; itemIndex < layer.Items.Count; itemIndex++) { + var item = layer.Items[itemIndex]; var sku = item.SkuType; if (sku == null) continue; @@ -43,6 +61,7 @@ public static LayerGeometry Build(Layer layer, SupportSurface supportSurface, in { // OccupancyGrid indexed as [width, length] => [y, x] geometry.OccupancyGrid[y, x] = true; + geometry.ItemIndexGrid[y, x] = itemIndex; } } // Store rectangle in pallet coordinates (origin bottom-left) diff --git a/Services/LayerGeometryOptimizer.cs b/Services/LayerGeometryOptimizer.cs index 69b5e10..c425bc8 100644 --- a/Services/LayerGeometryOptimizer.cs +++ b/Services/LayerGeometryOptimizer.cs @@ -2,6 +2,7 @@ namespace Stack_Solver.Services { + //TODO finish this public static class LayerGeometryOptimizer { public static void OptimizeGeometry(Layer layer) diff --git a/Services/LayerSupportAnalyzer.cs b/Services/LayerSupportAnalyzer.cs new file mode 100644 index 0000000..62eb4bd --- /dev/null +++ b/Services/LayerSupportAnalyzer.cs @@ -0,0 +1,79 @@ +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services +{ + public static class LayerSupportAnalyzer + { + /// + /// Analyzes the support of a layer placed on top of another layer and a support surface. + /// Computes the SKU overhang and the total unsupported area. + /// + /// If lowerLayer is null or contains no items, only the support surface is considered + /// for support calculations. + /// The optional lower layer to consider for support analysis. If provided and contains items, its geometry will + /// be used to determine the support for the upper layer. + /// The upper layer. + /// The support surface on which the layers are analyzed. + /// The size, in units, of each grid cell used for the analysis. Must be a positive, non-zero integer. + /// A LayerSupportMetrics object containing the total unsupported area and the maximum unsupported area for any + /// single SKU in the upper layer. + /// Thrown when gridStep is less than or equal to zero. + public static LayerSupportMetrics Analyze(Layer? lowerLayer, Layer upperLayer, SupportSurface supportSurface, int gridStep = 1) + { + ArgumentNullException.ThrowIfNull(upperLayer); + ArgumentNullException.ThrowIfNull(supportSurface); + if (gridStep <= 0) + throw new ArgumentOutOfRangeException(nameof(gridStep), "gridStep must be non-zero and positive"); + + var upperGeometry = LayerGeometryBuilder.Build(upperLayer, supportSurface, gridStep); + LayerGeometry? lowerGeometry = null; + if (lowerLayer != null && lowerLayer.Items.Count > 0) + { + lowerGeometry = LayerGeometryBuilder.Build(lowerLayer, supportSurface, gridStep); + } + + var upperMap = upperGeometry.ItemIndexGrid; + var lowerGrid = lowerGeometry?.OccupancyGrid; + double cellArea = gridStep * gridStep; + + var unsupportedCellCounts = new Dictionary(); + int width = upperGeometry.Width; + int length = upperGeometry.Length; + + for (int y = 0; y < width; y++) + { + for (int x = 0; x < length; x++) + { + int itemIndex = upperMap[y, x]; + if (itemIndex < 0) + continue; + + bool hasSupport = lowerGrid != null && + y < lowerGrid.GetLength(0) && + x < lowerGrid.GetLength(1) && + lowerGrid[y, x]; + + if (hasSupport) + continue; + + unsupportedCellCounts[itemIndex] = unsupportedCellCounts.TryGetValue(itemIndex, out var count) + ? count + 1 + : 1; + } + } + + double totalUnsupportedArea = 0; + double maxSkuOverhangArea = 0; + foreach (var kvp in unsupportedCellCounts) + { + double area = kvp.Value * cellArea; + totalUnsupportedArea += area; + if (area > maxSkuOverhangArea) + maxSkuOverhangArea = area; + } + + return new LayerSupportMetrics(totalUnsupportedArea, maxSkuOverhangArea); + } + } +} diff --git a/Services/LayerVisualizationService.cs b/Services/LayerVisualizationService.cs index 506814c..d3f3a3f 100644 --- a/Services/LayerVisualizationService.cs +++ b/Services/LayerVisualizationService.cs @@ -2,7 +2,6 @@ using Stack_Solver.Models.Layering; using Stack_Solver.Models.Supports; using System.Collections.ObjectModel; -using System.Windows; using System.Windows.Media; using System.Windows.Media.Media3D; diff --git a/Stack-Solver.csproj b/Stack-Solver.csproj index 92afd84..3891054 100644 --- a/Stack-Solver.csproj +++ b/Stack-Solver.csproj @@ -30,18 +30,23 @@ - - - - - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + + + @@ -58,12 +63,17 @@ + + + + + diff --git a/Tests/Stack-Solver.Tests/Services/LayerSupportAnalyzerTests.cs b/Tests/Stack-Solver.Tests/Services/LayerSupportAnalyzerTests.cs new file mode 100644 index 0000000..d57d8e5 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/LayerSupportAnalyzerTests.cs @@ -0,0 +1,82 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Metadata; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services; + +namespace Services +{ + public class LayerSupportAnalyzerTests + { + [Fact] + public void Analyze_NoLowerLayer_AllUpperCellsUnsupported() + { + var pallet = new Pallet("Test", 100, 100, 10); + var sku = CreateSku("A", 20, 20); + var upperLayer = CreateLayer("Upper", + new PositionedItem(sku, 0, 0, rotated: false)); + + var metrics = LayerSupportAnalyzer.Analyze(null, upperLayer, pallet, gridStep: 10); + + Assert.Equal(400, metrics.TotalUnsupportedArea, 3); + Assert.Equal(400, metrics.MaximumSkuOverhangArea, 3); + } + + [Fact] + public void Analyze_PartialSupport_ComputesUnsupportedArea() + { + var pallet = new Pallet("Test", 100, 100, 10); + var lowerSku = CreateSku("Lower", 20, 20); + var upperSku = CreateSku("Upper", 40, 20); + + var lowerLayer = CreateLayer("Lower", + new PositionedItem(lowerSku, 0, 0, rotated: false)); + var upperLayer = CreateLayer("Upper", + new PositionedItem(upperSku, 0, 0, rotated: false)); + + var metrics = LayerSupportAnalyzer.Analyze(lowerLayer, upperLayer, pallet, gridStep: 10); + + Assert.Equal(400, metrics.TotalUnsupportedArea, 3); + Assert.Equal(400, metrics.MaximumSkuOverhangArea, 3); + } + + [Fact] + public void Analyze_MultipleSkus_TracksWorstOverhang() + { + var pallet = new Pallet("Test", 120, 40, 10); + var supportSku = CreateSku("Support", 20, 20); + var skuA = CreateSku("A", 20, 20); + var skuB = CreateSku("B", 30, 20); + var skuC = CreateSku("C", 10, 20); + + var lowerLayer = CreateLayer("Lower", + new PositionedItem(supportSku, 0, 0, rotated: false)); + + var upperLayer = CreateLayer("Upper", + new PositionedItem(skuA, 0, 0, rotated: false), + new PositionedItem(skuB, 40, 0, rotated: false), + new PositionedItem(skuC, 80, 0, rotated: false)); + + var metrics = LayerSupportAnalyzer.Analyze(lowerLayer, upperLayer, pallet, gridStep: 10); + + Assert.Equal(800, metrics.TotalUnsupportedArea, 3); + Assert.Equal(600, metrics.MaximumSkuOverhangArea, 3); + } + + private static SKU CreateSku(string id, int length, int width) => new() + { + SkuId = id, + Name = id, + Length = length, + Width = width, + Height = 10, + Rotatable = false, + Quantity = 1 + }; + + private static Layer CreateLayer(string name, params PositionedItem[] items) + { + return new Layer(name, [.. items], new LayerMetadata(1.0, 1, name)); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs index 56d1258..5b0a18d 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs @@ -1,10 +1,8 @@ using Stack_Solver.Models; using Stack_Solver.Models.Supports; using Stack_Solver.Services.Layering; -using Xunit; - -namespace Stack_Solver.Tests.Strategies +namespace Services.Strategies { public class BLFGenerationStrategyTests { diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs index 25f1bf2..20be9ca 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs @@ -1,9 +1,8 @@ using Stack_Solver.Models; using Stack_Solver.Models.Supports; using Stack_Solver.Services.Layering; -using Xunit; -namespace Stack_Solver.Tests.Strategies +namespace Services.Strategies { public class HomogeneousGenerationStrategyTests { diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs index 6dc063a..c648f16 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs @@ -2,7 +2,7 @@ using Stack_Solver.Models.Supports; using Stack_Solver.Services.Layering; -namespace Stack_Solver.Tests.Strategies +namespace Services.Strategies { public class StripFillGenerationStrategyTests { @@ -36,7 +36,7 @@ public void Generate_SingleNonRotatableSKU_DeterministicCountsAndUtilization() Assert.Equal(3, layers.Count); var counts = layers.Select(l => l.Items.Count).OrderBy(x => x).ToArray(); - Assert.Equal(new[] { 3, 6, 9 }, counts); + Assert.Equal([3, 6, 9], counts); var best = layers.MaxBy(l => l.Metadata.Utilization)!; Assert.Equal(9, best.Items.Count); diff --git a/Tests/Stack-Solver.Tests/Validation/PalletSettingsDtoValidatorTests.cs b/Tests/Stack-Solver.Tests/Validation/PalletSettingsDtoValidatorTests.cs new file mode 100644 index 0000000..8a4dac5 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Validation/PalletSettingsDtoValidatorTests.cs @@ -0,0 +1,54 @@ +using Stack_Solver.Models.Inputs; +using Stack_Solver.Validation; + +namespace Validation +{ + public class PalletSettingsDtoValidatorTests + { + [Fact] + public void Validate_WithValidSettings_ReturnsValid() + { + var dto = new PalletSettingsDto + { + PalletLength = 120, + PalletWidth = 80, + PalletHeight = 15, + UseCpsat = true, + MaxCpsatCandidates = 1000, + BlfAttempts = 50, + SolverTimeLimit = 10, + MaxStackHeight = 180, + MaxStackWeight = 1000, + MaxSkuOverhang = 0 + }; + + var validator = new PalletSettingsDtoValidator(); + var result = validator.Validate(dto); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_WithInvalidSettings_ReturnsInvalid() + { + var dto = new PalletSettingsDto + { + PalletLength = 0, + PalletWidth = 0, + PalletHeight = 0, + UseCpsat = false, + MaxCpsatCandidates = 0, + BlfAttempts = -1, + SolverTimeLimit = 0, + MaxStackHeight = 0, + MaxStackWeight = 0, + MaxSkuOverhang = -1 + }; + + var validator = new PalletSettingsDtoValidator(); + var result = validator.Validate(dto); + + Assert.False(result.IsValid); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Validation/SkuInputDtoValidatorTests.cs b/Tests/Stack-Solver.Tests/Validation/SkuInputDtoValidatorTests.cs new file mode 100644 index 0000000..5ab80d8 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Validation/SkuInputDtoValidatorTests.cs @@ -0,0 +1,47 @@ +using Stack_Solver.Models.Inputs; +using Stack_Solver.Validation; + +namespace Validation +{ + public class SkuInputDtoValidatorTests + { + [Fact] + public void Validate_WithValidSku_ReturnsValid() + { + var dto = new SkuInputDto + { + Name = "Box", + Length = 10, + Width = 5, + Height = 2, + Weight = 1.5, + Rotatable = true, + Notes = "" + }; + + var validator = new SkuInputDtoValidator(); + var result = validator.Validate(dto); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_WithInvalidDimensions_ReturnsInvalid() + { + var dto = new SkuInputDto + { + Name = "Box", + Length = 0, + Width = -1, + Height = 0, + Weight = -0.1, + Rotatable = true + }; + + var validator = new SkuInputDtoValidator(); + var result = validator.Validate(dto); + + Assert.False(result.IsValid); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Validation/SkuQuantityDtoValidatorTests.cs b/Tests/Stack-Solver.Tests/Validation/SkuQuantityDtoValidatorTests.cs new file mode 100644 index 0000000..a5168af --- /dev/null +++ b/Tests/Stack-Solver.Tests/Validation/SkuQuantityDtoValidatorTests.cs @@ -0,0 +1,38 @@ +using Stack_Solver.Models.Inputs; +using Stack_Solver.Validation; + +namespace Validation +{ + public class SkuQuantityDtoValidatorTests + { + [Fact] + public void Validate_WithValidQuantity_ReturnsValid() + { + var dto = new SkuQuantityDto + { + SkuId = "sku-1", + Quantity = 10 + }; + + var validator = new SkuQuantityDtoValidator(); + var result = validator.Validate(dto); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_WithInvalidQuantity_ReturnsInvalid() + { + var dto = new SkuQuantityDto + { + SkuId = "", + Quantity = -1 + }; + + var validator = new SkuQuantityDtoValidator(); + var result = validator.Validate(dto); + + Assert.False(result.IsValid); + } + } +} diff --git a/Validation/PalletSettingsDtoValidator.cs b/Validation/PalletSettingsDtoValidator.cs new file mode 100644 index 0000000..ebefbac --- /dev/null +++ b/Validation/PalletSettingsDtoValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Stack_Solver.Models.Inputs; + +namespace Stack_Solver.Validation +{ + public class PalletSettingsDtoValidator : AbstractValidator + { + public PalletSettingsDtoValidator() + { + RuleFor(x => x.PalletLength).GreaterThan(0); + RuleFor(x => x.PalletWidth).GreaterThan(0); + RuleFor(x => x.PalletHeight).GreaterThan(0); + RuleFor(x => x.MaxCpsatCandidates).GreaterThan(0); + RuleFor(x => x.BlfAttempts).GreaterThanOrEqualTo(0); + RuleFor(x => x.SolverTimeLimit).GreaterThan(0); + RuleFor(x => x.MaxStackHeight).GreaterThan(0); + RuleFor(x => x.MaxStackWeight).GreaterThan(0); + RuleFor(x => x.MaxSkuOverhang).GreaterThanOrEqualTo(0); + } + } +} diff --git a/Validation/SkuInputDtoValidator.cs b/Validation/SkuInputDtoValidator.cs new file mode 100644 index 0000000..b98cd01 --- /dev/null +++ b/Validation/SkuInputDtoValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Stack_Solver.Models.Inputs; + +namespace Stack_Solver.Validation +{ + public class SkuInputDtoValidator : AbstractValidator + { + public SkuInputDtoValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Length).GreaterThan(0); + RuleFor(x => x.Width).GreaterThan(0); + RuleFor(x => x.Height).GreaterThan(0); + RuleFor(x => x.Weight).GreaterThanOrEqualTo(0); + } + } +} diff --git a/Validation/SkuQuantityDtoValidator.cs b/Validation/SkuQuantityDtoValidator.cs new file mode 100644 index 0000000..09f6b45 --- /dev/null +++ b/Validation/SkuQuantityDtoValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Stack_Solver.Models.Inputs; + +namespace Stack_Solver.Validation +{ + public class SkuQuantityDtoValidator : AbstractValidator + { + public SkuQuantityDtoValidator() + { + RuleFor(x => x.SkuId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThanOrEqualTo(0); + } + } +} diff --git a/Validation/ValidationErrorFormatter.cs b/Validation/ValidationErrorFormatter.cs new file mode 100644 index 0000000..974175c --- /dev/null +++ b/Validation/ValidationErrorFormatter.cs @@ -0,0 +1,20 @@ +using FluentValidation.Results; + +namespace Stack_Solver.Validation +{ + public static class ValidationErrorFormatter + { + public static string Format(IEnumerable errors) + { + var messages = errors + .Where(e => e != null) + .Select(e => $"{e.PropertyName}: {e.ErrorMessage}") + .Distinct() + .ToArray(); + + return messages.Length == 0 + ? "Validation failed." + : string.Join(Environment.NewLine, messages); + } + } +} diff --git a/ViewModels/Pages/LayerAnalyzerViewModel.cs b/ViewModels/Pages/LayerAnalyzerViewModel.cs index e578b28..0794728 100644 --- a/ViewModels/Pages/LayerAnalyzerViewModel.cs +++ b/ViewModels/Pages/LayerAnalyzerViewModel.cs @@ -85,6 +85,7 @@ public LayerAnalyzerViewModel(IEventAggregator events, ILayerVisualizationServic private int _solverTimeLimit; private int _maxStackHeight; private int _maxStackWeight; + private double _maxSkuOverhang; private List _selectedSkus = []; private void OnSettingsChanged(SettingsChangedMessage msg) @@ -98,6 +99,7 @@ private void OnSettingsChanged(SettingsChangedMessage msg) _solverTimeLimit = msg.SolverTimeLimit; _maxStackHeight = msg.MaxStackHeight; _maxStackWeight = msg.MaxStackWeight; + _maxSkuOverhang = msg.MaxSkuOverhang; _selectedSkus = [.. msg.Skus.Where(s => s.Quantity > 0)]; RecenterCameraTarget(); if (SelectedLayer != null) diff --git a/ViewModels/Pages/PalletBuilderSettingsViewModel.cs b/ViewModels/Pages/PalletBuilderSettingsViewModel.cs index a6f0e38..1bca60b 100644 --- a/ViewModels/Pages/PalletBuilderSettingsViewModel.cs +++ b/ViewModels/Pages/PalletBuilderSettingsViewModel.cs @@ -1,8 +1,11 @@ +using FluentValidation; using Microsoft.Extensions.Options; using Stack_Solver.Data.Repositories; using Stack_Solver.Infrastructure; using Stack_Solver.Models; +using Stack_Solver.Models.Inputs; using Stack_Solver.Models.Supports; +using Stack_Solver.Validation; using System.Collections.ObjectModel; namespace Stack_Solver.ViewModels.Pages @@ -11,6 +14,8 @@ public partial class PalletBuilderSettingsViewModel : ObservableObject { private readonly ISkuRepository _skuRepository; private readonly IEventAggregator _events; + private readonly IValidator _settingsValidator; + private readonly IValidator _skuQuantityValidator; private readonly GenerationOptions _defaults; private readonly PalletDefaultsOptions _palletDefaults; private bool _isInitialized; @@ -45,6 +50,19 @@ public partial class PalletBuilderSettingsViewModel : ObservableObject [ObservableProperty] private int _maxStackWeight; + private double _maxSkuOverhang; + public double MaxSkuOverhang + { + get => _maxSkuOverhang; + set + { + if (SetProperty(ref _maxSkuOverhang, value)) + { + PublishSettingsChanged(); + } + } + } + public ObservableCollection CommonPalletsInternational { get; } = []; public ObservableCollection CommonPalletsAmerica { get; } = []; @@ -74,10 +92,18 @@ public Pallet? SelectedAmericanPallet } } - public PalletBuilderSettingsViewModel(ISkuRepository skuRepository, IEventAggregator events, IOptions genOptions, IOptions palletDefaults) + public PalletBuilderSettingsViewModel( + ISkuRepository skuRepository, + IEventAggregator events, + IOptions genOptions, + IOptions palletDefaults, + IValidator settingsValidator, + IValidator skuQuantityValidator) { _skuRepository = skuRepository; _events = events; + _settingsValidator = settingsValidator; + _skuQuantityValidator = skuQuantityValidator; _defaults = GenerationOptions.From(genOptions.Value); _palletDefaults = palletDefaults.Value ?? new PalletDefaultsOptions(); _skuRepository.SkuAdded += OnSkuAdded; @@ -94,6 +120,7 @@ public PalletBuilderSettingsViewModel(ISkuRepository skuRepository, IEventAggreg MaxStackHeight = _palletDefaults.MaxStackHeight; MaxStackWeight = _palletDefaults.MaxStackWeight; + MaxSkuOverhang = _palletDefaults.MaxSkuOverhang; } public async Task InitializeAsync() @@ -145,6 +172,16 @@ private void SelectPallet(Pallet? pallet) public async Task UpdateSkuAsync(SKU sku, CancellationToken ct = default) { if (sku == null) return; + var dto = new SkuQuantityDto + { + SkuId = sku.SkuId, + Quantity = sku.Quantity + }; + var result = _skuQuantityValidator.Validate(dto); + if (!result.IsValid) + { + throw new ValidationException(ValidationErrorFormatter.Format(result.Errors)); + } await _skuRepository.UpdateAsync(sku, ct); PublishSettingsChanged(); } @@ -160,10 +197,29 @@ public async Task UpdateSkuAsync(SKU sku, CancellationToken ct = default) private void PublishSettingsChanged() { + var dto = new PalletSettingsDto + { + PalletLength = PalletLength, + PalletWidth = PalletWidth, + PalletHeight = PalletHeight, + UseCpsat = UseCpsat, + MaxCpsatCandidates = MaxCpsatCandidates, + BlfAttempts = BlfAttempts, + SolverTimeLimit = SolverTimeLimit, + MaxStackHeight = MaxStackHeight, + MaxStackWeight = MaxStackWeight, + MaxSkuOverhang = MaxSkuOverhang + }; + var result = _settingsValidator.Validate(dto); + if (!result.IsValid) + { + var _ = ValidationErrorFormatter.Format(result.Errors); + return; + } _events.Publish(new SettingsChangedMessage( PalletLength, PalletWidth, PalletHeight, UseCpsat, MaxCpsatCandidates, BlfAttempts, SolverTimeLimit, - MaxStackHeight, MaxStackWeight, + MaxStackHeight, MaxStackWeight, MaxSkuOverhang, [.. Skus])); } @@ -256,5 +312,6 @@ public record SettingsChangedMessage( int SolverTimeLimit, int MaxStackHeight, int MaxStackWeight, + double MaxSkuOverhang, List Skus); } diff --git a/ViewModels/Pages/PalletBuilderViewModel.cs b/ViewModels/Pages/PalletBuilderViewModel.cs index 5bebe6b..a1385fa 100644 --- a/ViewModels/Pages/PalletBuilderViewModel.cs +++ b/ViewModels/Pages/PalletBuilderViewModel.cs @@ -1,24 +1,25 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using FluentValidation; using Microsoft.Extensions.Options; using Stack_Solver.Data.Repositories; using Stack_Solver.Infrastructure; using Stack_Solver.Models; +using Stack_Solver.Models.Inputs; using Stack_Solver.Services; namespace Stack_Solver.ViewModels.Pages { - public partial class PalletBuilderViewModel : ObservableObject + public partial class PalletBuilderViewModel( + ISkuRepository skuRepository, + IEventAggregator events, + ILayerVisualizationService viz, + IOptions genOptions, + IOptions palletDefaults, + IValidator settingsValidator, + IValidator skuQuantityValidator) : ObservableObject { - public PalletBuilderSettingsViewModel Settings { get; } - public LayerAnalyzerViewModel LayerAnalyzer { get; } - public PalletAnalyzerViewModel PalletAnalyzer { get; } - - public PalletBuilderViewModel(ISkuRepository skuRepository, IEventAggregator events, ILayerVisualizationService viz, IOptions genOptions, IOptions palletDefaults) - { - Settings = new PalletBuilderSettingsViewModel(skuRepository, events, genOptions, palletDefaults); - LayerAnalyzer = new LayerAnalyzerViewModel(events, viz); - PalletAnalyzer = new PalletAnalyzerViewModel(events); - } + public PalletBuilderSettingsViewModel Settings { get; } = new PalletBuilderSettingsViewModel(skuRepository, events, genOptions, palletDefaults, settingsValidator, skuQuantityValidator); + public LayerAnalyzerViewModel LayerAnalyzer { get; } = new LayerAnalyzerViewModel(events, viz); + public PalletAnalyzerViewModel PalletAnalyzer { get; } = new PalletAnalyzerViewModel(events); public async Task OnNavigatedToAsync() { diff --git a/ViewModels/Pages/SKULibraryViewModel.cs b/ViewModels/Pages/SKULibraryViewModel.cs index 5e2e51b..82e8dbb 100644 --- a/ViewModels/Pages/SKULibraryViewModel.cs +++ b/ViewModels/Pages/SKULibraryViewModel.cs @@ -1,5 +1,8 @@ -using Stack_Solver.Data.Repositories; +using FluentValidation; +using Stack_Solver.Data.Repositories; using Stack_Solver.Models; +using Stack_Solver.Models.Inputs; +using Stack_Solver.Validation; using System.Collections.ObjectModel; namespace Stack_Solver.ViewModels.Pages @@ -7,14 +10,16 @@ namespace Stack_Solver.ViewModels.Pages public partial class SKULibraryViewModel : ObservableObject { private readonly ISkuRepository _skuRepository; + private readonly IValidator _skuValidator; private bool _isInitialized = false; [ObservableProperty] private ObservableCollection _skus = []; - public SKULibraryViewModel(ISkuRepository skuRepository) + public SKULibraryViewModel(ISkuRepository skuRepository, IValidator skuValidator) { _skuRepository = skuRepository; + _skuValidator = skuValidator; _ = InitializeViewModelAsync(); } @@ -24,13 +29,14 @@ private async Task AddSkuAsync() var newSku = new SKU { Name = "New SKU", - Length = 0, - Width = 0, - Height = 0, + Length = 1, + Width = 1, + Height = 1, Weight = 0, Notes = "", Rotatable = true }; + ValidateSku(newSku); await _skuRepository.AddAsync(newSku); Skus.Add(newSku); } @@ -38,6 +44,7 @@ private async Task AddSkuAsync() [RelayCommand] private async Task SaveSkuAsync(SKU sku) { + ValidateSku(sku); await _skuRepository.UpdateAsync(sku); } @@ -63,5 +70,25 @@ private async Task InitializeViewModelAsync() Skus = new ObservableCollection(list); _isInitialized = true; } + + private void ValidateSku(SKU sku) + { + if (sku == null) return; + var dto = new SkuInputDto + { + Name = sku.Name, + Length = sku.Length, + Width = sku.Width, + Height = sku.Height, + Weight = sku.Weight, + Rotatable = sku.Rotatable, + Notes = sku.Notes + }; + var result = _skuValidator.Validate(dto); + if (!result.IsValid) + { + throw new ValidationException(ValidationErrorFormatter.Format(result.Errors)); + } + } } } diff --git a/Views/Pages/DashboardPage.xaml b/Views/Pages/DashboardPage.xaml index 1b05896..9329536 100644 --- a/Views/Pages/DashboardPage.xaml +++ b/Views/Pages/DashboardPage.xaml @@ -16,52 +16,169 @@ Foreground="{DynamicResource TextFillColorPrimaryBrush}" mc:Ignorable="d"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + - - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + For more details, check the GitHub - + page. diff --git a/Views/Pages/PalletBuilderPage.xaml b/Views/Pages/PalletBuilderPage.xaml index 480d0de..983fe97 100644 --- a/Views/Pages/PalletBuilderPage.xaml +++ b/Views/Pages/PalletBuilderPage.xaml @@ -33,7 +33,7 @@ - + @@ -65,7 +65,7 @@ - + @@ -183,7 +183,7 @@ - + @@ -191,12 +191,19 @@ + + + - + @@ -240,7 +247,7 @@ - + diff --git a/Views/Pages/PalletBuilderPage.xaml.cs b/Views/Pages/PalletBuilderPage.xaml.cs index 3d60713..8cfaa4e 100644 --- a/Views/Pages/PalletBuilderPage.xaml.cs +++ b/Views/Pages/PalletBuilderPage.xaml.cs @@ -1,4 +1,5 @@ -using Stack_Solver.Models; +using FluentValidation; +using Stack_Solver.Models; using Stack_Solver.Models.Layering; using Stack_Solver.ViewModels.Pages; using System.Windows.Controls; @@ -39,16 +40,10 @@ private void MainViewPort_MouseLeftButtonDown(object sender, MouseButtonEventArg HitTestResultBehavior resultCallback(HitTestResult r) { - if (r is RayHitTestResult rayResult) + if (r is RayHitTestResult rayResult && rayResult.ModelHit is GeometryModel3D geo && ViewModel.LayerAnalyzer.TryGetItemFromGeometry(geo, out var item)) { - if (rayResult.ModelHit is GeometryModel3D geo) - { - if (ViewModel.LayerAnalyzer.TryGetItemFromGeometry(geo, out var item)) - { - selected = item; - return HitTestResultBehavior.Stop; - } - } + selected = item; + return HitTestResultBehavior.Stop; } return HitTestResultBehavior.Continue; } @@ -73,8 +68,10 @@ private async void SkuSelectionGrid_CellEditEnding(object sender, DataGridCellEd { await ViewModel.Settings.UpdateSkuAsync(sku); } - catch + catch (ValidationException ex) { + var message = string.Join(Environment.NewLine, ex.Errors.Select(e => e.ErrorMessage)); + MessageBox.Show(message, "Validation error", MessageBoxButton.OK, MessageBoxImage.Warning); } } } diff --git a/Views/Pages/SKULibraryPage.xaml.cs b/Views/Pages/SKULibraryPage.xaml.cs index ac8ae48..b098593 100644 --- a/Views/Pages/SKULibraryPage.xaml.cs +++ b/Views/Pages/SKULibraryPage.xaml.cs @@ -1,4 +1,5 @@ -using Stack_Solver.Models; +using FluentValidation; +using Stack_Solver.Models; using Stack_Solver.ViewModels.Pages; using System.Windows.Controls; using System.Windows.Data; @@ -56,9 +57,10 @@ private void SkuDataGrid_CellEditEnding(object sender, DataGridCellEditEndingEve if (ViewModel.SaveSkuCommand is IRelayCommand cmd && cmd.CanExecute(sku)) cmd.Execute(sku); } - catch + catch (ValidationException ex) { - + var message = string.Join(Environment.NewLine, ex.Errors.Select(e => e.ErrorMessage)); + MessageBox.Show(message, "Validation error", MessageBoxButton.OK, MessageBoxImage.Warning); } } } diff --git a/Views/Windows/MainWindow.xaml.cs b/Views/Windows/MainWindow.xaml.cs index e1f65e3..1d0f6fc 100644 --- a/Views/Windows/MainWindow.xaml.cs +++ b/Views/Windows/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using Stack_Solver.ViewModels.Windows; +using Stack_Solver.Views.Pages; using Wpf.Ui; using Wpf.Ui.Abstractions; using Wpf.Ui.Appearance; @@ -25,6 +26,7 @@ INavigationService navigationService SetPageService(navigationViewPageProvider); navigationService.SetNavigationControl(RootNavigation); + RootNavigation.Navigated += OnRootNavigationNavigated; } #region INavigationWindow methods @@ -41,9 +43,6 @@ INavigationService navigationService #endregion INavigationWindow methods - /// - /// Raises the closed event. - /// protected override void OnClosed(EventArgs e) { base.OnClosed(e); @@ -61,5 +60,17 @@ public void SetServiceProvider(IServiceProvider serviceProvider) { throw new NotImplementedException(); } + + private void OnRootNavigationNavigated(NavigationView sender, NavigatedEventArgs args) + { + if (BreadcrumbBar is null) + { + return; + } + + BreadcrumbBar.Visibility = args?.Page is DashboardPage + ? Visibility.Collapsed + : Visibility.Visible; + } } } diff --git a/defaults.json b/defaults.json index b020653..54510fc 100644 --- a/defaults.json +++ b/defaults.json @@ -1,8 +1,30 @@ { + "Serilog": { + "Using": [ "Serilog.Sinks.File" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "Enrich": [ "FromLogContext" ], + "WriteTo": [ + { + "Name": "File", + "Args": { + "path": "%LocalAppData%\\StackSolver\\logs\\app-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 14, + "shared": true + } + } + ] + }, "LayerGeneration": { "MaxSolverTime": 60, "MaxCPSATCandidates": 2000, - "BLFAttempts": 200 + "BLFAttempts": 200 }, "PalletDefaults": { "DefaultCatalog": "International", @@ -11,6 +33,7 @@ "PalletWidth": 80, "PalletHeight": 14.4, "MaxStackHeight": 180, - "MaxStackWeight": 950 + "MaxStackWeight": 950, + "MaxSkuOverhang": 0 } }