diff --git a/App.xaml b/App.xaml index 6c0f5dc..3d9f69b 100644 --- a/App.xaml +++ b/App.xaml @@ -11,6 +11,7 @@ + diff --git a/App.xaml.cs b/App.xaml.cs index 96bc8b6..3d682ea 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -60,6 +60,8 @@ public partial class App // Service containing navigation, same as INavigationWindow... but without window services.AddSingleton(); + services.AddSingleton(); + // Main window with navigation services.AddSingleton(); services.AddSingleton(); @@ -81,7 +83,8 @@ public partial class App sp.GetRequiredService>(), sp.GetRequiredService>(), sp.GetRequiredService>(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Converters/BoolToInfoBarSeverityConverter.cs b/Converters/BoolToInfoBarSeverityConverter.cs new file mode 100644 index 0000000..04ae261 --- /dev/null +++ b/Converters/BoolToInfoBarSeverityConverter.cs @@ -0,0 +1,15 @@ +using System.Globalization; +using System.Windows.Data; +using Wpf.Ui.Controls; + +namespace Stack_Solver.Converters +{ + public class BoolToInfoBarSeverityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && b ? InfoBarSeverity.Success : InfoBarSeverity.Error; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } +} diff --git a/Helpers/Rendering/PalletSceneBuilder.cs b/Helpers/Rendering/PalletSceneBuilder.cs index 3fded59..083d74f 100644 --- a/Helpers/Rendering/PalletSceneBuilder.cs +++ b/Helpers/Rendering/PalletSceneBuilder.cs @@ -9,6 +9,14 @@ public class PalletSceneBuilder private readonly Dictionary _skuBrushCache = []; private readonly Lock _cacheLock = new(); + private Dictionary _geometryToLayerId = []; + private IReadOnlyList<(string LayerId, double Y, double Height)> _layerPositions = []; + + public bool TryGetLayerIdForGeometry(GeometryModel3D geo, out string layerId) + => _geometryToLayerId.TryGetValue(geo, out layerId!); + + public IReadOnlyList<(string LayerId, double Y, double Height)> LayerPositions => _layerPositions; + public async Task BuildAsync( Model3DGroup target, PalletTemplate template, @@ -21,6 +29,8 @@ public async Task BuildAsync( { ct.ThrowIfCancellationRequested(); var g = new Model3DGroup(); + var mapping = new Dictionary(); + var positions = new List<(string LayerId, double Y, double Height)>(); g.Children.Add(new AmbientLight(Colors.DimGray)); g.Children.Add(new DirectionalLight(Colors.White, new Vector3D(-1, -2, -1))); @@ -35,6 +45,7 @@ public async Task BuildAsync( foreach (var layer in template.Layers) { ct.ThrowIfCancellationRequested(); + positions.Add((layer.Id, currentY, layer.Metadata.Height)); foreach (var item in layer.Items) { @@ -43,21 +54,32 @@ public async Task BuildAsync( double boxWidth = item.Rotated ? sku.Length : sku.Width; var origin = new Point3D(item.X, currentY, item.Y); var brush = GetBrushForSku(sku.SkuId); - g.Children.Add(GeometryCreator.CreateBoxWithEdges( - origin, boxLength, sku.Height, boxWidth, brush, Colors.Black, 0.25)); + var boxGroup = GeometryCreator.CreateBoxWithEdges( + origin, boxLength, sku.Height, boxWidth, brush, Colors.Black, 0.25); + g.Children.Add(boxGroup); + if (boxGroup is Model3DGroup boxModelGroup) + { + foreach (var child in boxModelGroup.Children) + { + if (child is GeometryModel3D geo) + mapping[geo] = layer.Id; + } + } } currentY += layer.Metadata.Height; } TryFreezeRecursive(g); - return g; + return (Group: g, Mapping: mapping, Positions: positions); }, ct).ConfigureAwait(true); ct.ThrowIfCancellationRequested(); target.Children.Clear(); - foreach (var child in tempGroup.Children) + foreach (var child in tempGroup.Group.Children) target.Children.Add(child); + _geometryToLayerId = tempGroup.Mapping; + _layerPositions = tempGroup.Positions; } private Brush GetBrushForSku(string skuId) diff --git a/Services/CPSATAssignmentService.cs b/Services/CPSATAssignmentService.cs new file mode 100644 index 0000000..ed3e405 --- /dev/null +++ b/Services/CPSATAssignmentService.cs @@ -0,0 +1,172 @@ +using Google.OrTools.Sat; +using Stack_Solver.Models; +using Stack_Solver.Models.Assignment; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services +{ + /// + /// Assigns pre-built pallet templates to demand using CP-SAT with strict + /// three-tier lexicographic optimization: minimize leftovers (Phase 1), + /// then total pallets (Phase 2), then distinct templates (Phase 3). Each + /// phase pins the previous objective as an equality constraint before the + /// next solve. A greedy AssignmentResult may be supplied as a warm-start; + /// hints are best-effort and silently ignored by the solver if infeasible. + /// + public static class CPSATAssignmentService + { + public static AssignmentResult Assign( + IReadOnlyList templates, + IReadOnlyDictionary demand, + Pallet pallet, + GenerationOptions options, + AssignmentResult? warmStart = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(templates); + ArgumentNullException.ThrowIfNull(demand); + ArgumentNullException.ThrowIfNull(pallet); + ArgumentNullException.ThrowIfNull(options); + + if (templates.Count == 0 || demand.Count == 0) + return new AssignmentResult(); + + // Drop templates that pack SKUs not present in demand. Should not + // arise in normal flow, but keeps the model self-consistent. + var validTemplates = templates + .Where(t => t.SkuCounts.Keys.All(demand.ContainsKey)) + .ToList(); + if (validTemplates.Count == 0) + return new AssignmentResult(); + + var skuOrder = demand.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList(); + int dMax = Math.Max(1, demand.Values.Sum()); + + var model = new CpModel(); + var x = new IntVar[validTemplates.Count]; + var y = new BoolVar[validTemplates.Count]; + + for (int t = 0; t < validTemplates.Count; t++) + { + x[t] = model.NewIntVar(0, dMax, $"x_{t}"); + y[t] = model.NewBoolVar($"y_{t}"); + // y_t = 1 ⟺ x_t ≥ 1 + model.Add(x[t] >= 1).OnlyEnforceIf(y[t]); + model.Add(x[t] == 0).OnlyEnforceIf(y[t].Not()); + } + + var l = new Dictionary(StringComparer.Ordinal); + foreach (var sku in skuOrder) + l[sku] = model.NewIntVar(0, demand[sku], $"l_{sku}"); + + // Demand satisfaction with leftover slack: Σ_t a_{t,i} · x_t + l_i = d_i + foreach (var sku in skuOrder) + { + var terms = new List { l[sku] }; + for (int t = 0; t < validTemplates.Count; t++) + { + int count = validTemplates[t].SkuCounts.GetValueOrDefault(sku); + if (count > 0) + terms.Add(LinearExpr.Term(x[t], count)); + } + model.Add(LinearExpr.Sum(terms) == demand[sku]); + } + + if (warmStart != null) + ApplyWarmStart(model, warmStart, validTemplates, x, y, l); + + int totalBudget = options.MaxSolverTime > 0 ? options.MaxSolverTime : 30; + int phaseBudget = Math.Max(5, totalBudget / 3); + + var solver = new CpSolver + { + StringParameters = $"max_time_in_seconds:{phaseBudget},num_search_workers:8" + }; + + // Phase 1: minimize leftovers. + ct.ThrowIfCancellationRequested(); + var leftoversSum = LinearExpr.Sum(l.Values); + model.Minimize(leftoversSum); + if (!Solved(solver.Solve(model))) return new AssignmentResult(); + long leftoversStar = (long)Math.Round(solver.ObjectiveValue); + model.Add(leftoversSum == leftoversStar); + + // Phase 2: minimize total pallets. + ct.ThrowIfCancellationRequested(); + var palletsSum = LinearExpr.Sum(x); + model.Minimize(palletsSum); + if (!Solved(solver.Solve(model))) return new AssignmentResult(); + long palletsStar = (long)Math.Round(solver.ObjectiveValue); + model.Add(palletsSum == palletsStar); + + // Phase 3: minimize distinct templates. + ct.ThrowIfCancellationRequested(); + model.Minimize(LinearExpr.Sum(y)); + if (!Solved(solver.Solve(model))) return new AssignmentResult(); + + return ExtractResult(solver, validTemplates, x, l); + } + + private static bool Solved(CpSolverStatus status) => + status == CpSolverStatus.Optimal || status == CpSolverStatus.Feasible; + + private static AssignmentResult ExtractResult( + CpSolver solver, + IReadOnlyList templates, + IntVar[] x, + IReadOnlyDictionary l) + { + var assignments = new List<(PalletTemplate Template, int Count)>(); + for (int t = 0; t < templates.Count; t++) + { + int count = (int)solver.Value(x[t]); + if (count > 0) + assignments.Add((templates[t], count)); + } + + var leftovers = new Dictionary(StringComparer.Ordinal); + foreach (var (sku, lVar) in l) + { + int count = (int)solver.Value(lVar); + if (count > 0) + leftovers[sku] = count; + } + + return new AssignmentResult { Assignments = assignments, Leftovers = leftovers }; + } + + private static void ApplyWarmStart( + CpModel model, + AssignmentResult warmStart, + IReadOnlyList templates, + IntVar[] x, + BoolVar[] y, + IReadOnlyDictionary l) + { + // Greedy templates won't share IDs with the enumerator pool, so match + // by SKU-count signature (the same signature the enumerator dedups on). + var greedyCounts = new Dictionary(StringComparer.Ordinal); + foreach (var (template, count) in warmStart.Assignments) + { + var sig = Signature(template); + greedyCounts[sig] = greedyCounts.GetValueOrDefault(sig) + count; + } + + for (int t = 0; t < templates.Count; t++) + { + int hint = greedyCounts.GetValueOrDefault(Signature(templates[t])); + model.AddHint(x[t], hint); + model.AddHint(y[t], hint > 0 ? 1 : 0); + } + + foreach (var (sku, count) in warmStart.Leftovers) + if (l.TryGetValue(sku, out var lVar)) + model.AddHint(lVar, count); + } + + private static string Signature(PalletTemplate t) => + string.Join("|", t.SkuCounts + .OrderBy(kvp => kvp.Key, StringComparer.Ordinal) + .Select(kvp => $"{kvp.Key}:{kvp.Value}")); + } +} diff --git a/Services/Layering/HomogeneousGenerationStrategy.cs b/Services/Layering/HomogeneousGenerationStrategy.cs index d703668..bae309e 100644 --- a/Services/Layering/HomogeneousGenerationStrategy.cs +++ b/Services/Layering/HomogeneousGenerationStrategy.cs @@ -76,7 +76,7 @@ public List Generate(List skus, SupportSurface supportSurface, Gener string description = $"homogeneous {variant.Sku.Name} ({orientation}) {nx}x{ny}"; var metadata = new LayerMetadata(utilization, variant.Sku.Height, description); - var layer = new Layer($"hom_grid_{variant.Sku.Name}", placements, metadata); + var layer = new Layer($"hom_grid_{variant.Sku.Name}_{orientation}", placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); return layer; diff --git a/Services/Stacking/PalletTemplateEnumerator.cs b/Services/Stacking/PalletTemplateEnumerator.cs index 385c661..b3953cc 100644 --- a/Services/Stacking/PalletTemplateEnumerator.cs +++ b/Services/Stacking/PalletTemplateEnumerator.cs @@ -5,58 +5,168 @@ namespace Stack_Solver.Services.Stacking { /// - /// Generates a diverse pool of pallet templates from a filtered layer set by running - /// the greedy stacking strategy with different layer orderings. + /// Generates a pool of pallet templates from a filtered layer set in three classes: + /// (1) pure homogeneous chains, (2) stacked-homogeneous pairs, + /// (3) mixed-layer combinations /// public static class PalletTemplateEnumerator { - private static readonly GreedyStackingStrategy _strategy = new(); + private const int MaxDistinctSkusPerTemplate = 3; + private const int MaxLayersPerTemplate = 6; public static List Enumerate( Pallet pallet, - IReadOnlyList layers, - GenerationOptions options) + IReadOnlyList layers) { if (layers.Count == 0) return []; var templates = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var supportCache = new Dictionary<(string, string), bool>(); - // Ordering 1: heaviest layers first (plan §5: heavy on bottom) - TryAdd(pallet, layers.OrderByDescending(l => l.Metrics.TotalWeight).ToList(), options, templates); + var homogeneous = layers.Where(IsHomogeneous).ToList(); + var mixed = layers.Where(l => !IsHomogeneous(l)).ToList(); - // Ordering 2: highest utilization first - TryAdd(pallet, layers.OrderByDescending(l => l.Metadata.Utilization).ToList(), options, templates); + foreach (var layer in homogeneous) + TryAddRepeated(pallet, layer, templates, seen); - // Ordering 3: each layer as mandatory base, rest sorted by weight - foreach (var baseLayer in layers) + foreach (var bottom in homogeneous) { - var ordered = layers - .Where(l => !ReferenceEquals(l, baseLayer)) - .OrderByDescending(l => l.Metrics.TotalWeight) - .Prepend(baseLayer) - .ToList(); - TryAdd(pallet, ordered, options, templates); + foreach (var top in homogeneous) + { + if (ReferenceEquals(bottom, top)) continue; + if (SkuOf(bottom) == SkuOf(top)) continue; + if (bottom.Metrics.TotalWeight < top.Metrics.TotalWeight) continue; + if (!IsTransitionValid(bottom, top, pallet, supportCache)) continue; + + EnumerateStackedPair(pallet, bottom, top, templates, seen); + } + } + + foreach (var m in mixed) + { + TryAddRepeated(pallet, m, templates, seen); + + foreach (var hom in homogeneous) + { + if (DistinctSkuUnion(m, hom) > MaxDistinctSkusPerTemplate) continue; + + if (m.Metrics.TotalWeight >= hom.Metrics.TotalWeight && + IsTransitionValid(m, hom, pallet, supportCache)) + { + EnumerateStackedPair(pallet, m, hom, templates, seen); + } + + if (hom.Metrics.TotalWeight >= m.Metrics.TotalWeight && + IsTransitionValid(hom, m, pallet, supportCache)) + { + EnumerateStackedPair(pallet, hom, m, templates, seen); + } + } } return templates; } - private static void TryAdd( + private static void TryAddRepeated( + Pallet pallet, + Layer layer, + List templates, + HashSet seen) + { + int n = Math.Min(MaxRepeats(pallet, layer), MaxLayersPerTemplate); + if (n < 1) return; + + var stack = new List(n); + for (int i = 0; i < n; i++) stack.Add(layer); + AddIfDistinct(stack, templates, seen); + } + + private static void EnumerateStackedPair( + Pallet pallet, + Layer bottom, + Layer top, + List templates, + HashSet seen) + { + int availH = pallet.MaxStackHeight - pallet.Height; + int availW = pallet.MaxStackWeight; + int maxBottom = Math.Min(MaxRepeats(pallet, bottom), MaxLayersPerTemplate - 1); + int maxTop = Math.Min(MaxRepeats(pallet, top), MaxLayersPerTemplate - 1); + + for (int nB = 1; nB <= maxBottom; nB++) + { + int hUsedB = nB * bottom.Metadata.Height; + double wUsedB = nB * bottom.Metrics.TotalWeight; + if (hUsedB > availH || wUsedB > availW) break; + + int remainingLayers = MaxLayersPerTemplate - nB; + int topCap = Math.Min(maxTop, remainingLayers); + + for (int nT = 1; nT <= topCap; nT++) + { + int hUsed = hUsedB + nT * top.Metadata.Height; + double wUsed = wUsedB + nT * top.Metrics.TotalWeight; + if (hUsed > availH || wUsed > availW) break; + + var stack = new List(nB + nT); + for (int i = 0; i < nB; i++) stack.Add(bottom); + for (int i = 0; i < nT; i++) stack.Add(top); + AddIfDistinct(stack, templates, seen); + } + } + } + + private static int MaxRepeats(Pallet pallet, Layer layer) + { + if (layer.Metadata.Height <= 0) return 0; + int availH = pallet.MaxStackHeight - pallet.Height; + if (availH <= 0) return 0; + + int byH = availH / layer.Metadata.Height; + int byW = layer.Metrics.TotalWeight > 0 + ? (int)Math.Floor(pallet.MaxStackWeight / layer.Metrics.TotalWeight) + : int.MaxValue; + return Math.Min(byH, byW); + } + + private static bool IsTransitionValid( + Layer lower, + Layer upper, Pallet pallet, - IReadOnlyList ordered, - GenerationOptions options, - List templates) + Dictionary<(string, string), bool> cache) + { + var key = (lower.Id, upper.Id); + if (cache.TryGetValue(key, out bool cached)) return cached; + + var support = LayerSupportAnalyzer.Analyze(lower, upper, pallet); + bool ok = support.MaximumSkuOverhangArea <= pallet.MaxSkuOverhang; + cache[key] = ok; + return ok; + } + + private static int DistinctSkuUnion(Layer a, Layer b) { - var t = _strategy.Build(pallet, ordered, options); - if (t != null && IsDistinct(t, templates)) - templates.Add(t); + var union = new HashSet(a.Metrics.UsedSkuTypes, StringComparer.Ordinal); + foreach (var s in b.Metrics.UsedSkuTypes) union.Add(s); + return union.Count; } - private static bool IsDistinct(PalletTemplate candidate, IReadOnlyList existing) + private static bool IsHomogeneous(Layer l) => + l.Metrics.UsedSkuTypes.Count == 1; + + private static string SkuOf(Layer l) => + l.Metrics.UsedSkuTypes.First(); + + private static void AddIfDistinct( + IReadOnlyList layers, + List templates, + HashSet seen) { - var sig = Signature(candidate); - return existing.All(t => Signature(t) != sig); + var template = PalletTemplate.FromLayers(layers); + if (seen.Add(Signature(template))) + templates.Add(template); } private static string Signature(PalletTemplate t) => diff --git a/Services/Stacking/TemplateFilter.cs b/Services/Stacking/TemplateFilter.cs new file mode 100644 index 0000000..bff2f61 --- /dev/null +++ b/Services/Stacking/TemplateFilter.cs @@ -0,0 +1,101 @@ +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services.Stacking +{ + /// + /// Prunes the enumerated template pool before it reaches the assignment stage. + /// Applies a complexity cap (≤3 distinct SKUs) and a minimum-utilization floor, + /// then drops Pareto-dominated templates: T dominates U iff T packs ≥ U's count + /// of every SKU at ≤ U's height and weight, with at least one strict inequality. + /// + public static class TemplateFilter + { + private const int MaxDistinctSkusPerTemplate = 3; + private const double MinUtilization = 0.60; + + public static List Filter(IReadOnlyList templates, Pallet pallet) + { + ArgumentNullException.ThrowIfNull(templates); + ArgumentNullException.ThrowIfNull(pallet); + if (templates.Count == 0) return []; + + double palletVolume = (double)pallet.Length * pallet.Width * Math.Max(0, pallet.MaxStackHeight - pallet.Height); + double maxWeight = pallet.MaxStackWeight; + + var afterCaps = new List(templates.Count); + foreach (var t in templates) + { + if (t.SkuCounts.Count == 0 || t.SkuCounts.Count > MaxDistinctSkusPerTemplate) continue; + if (!PassesUtilizationFloor(t, palletVolume, maxWeight)) continue; + afterCaps.Add(t); + } + + return RemoveDominated(afterCaps); + } + + private static bool PassesUtilizationFloor(PalletTemplate t, double palletVolume, double maxWeight) + { + double volumeUtil = palletVolume > 0 ? UsedVolume(t) / palletVolume : 0; + double weightUtil = maxWeight > 0 ? t.TotalWeight / maxWeight : 0; + return Math.Max(volumeUtil, weightUtil) >= MinUtilization; + } + + private static double UsedVolume(PalletTemplate t) + { + double v = 0; + foreach (var layer in t.Layers) + foreach (var item in layer.Items) + v += (double)item.SkuType.Length * item.SkuType.Width * item.SkuType.Height; + return v; + } + + private static List RemoveDominated(List templates) + { + var kept = new List(templates.Count); + for (int i = 0; i < templates.Count; i++) + { + bool dominated = false; + for (int j = 0; j < templates.Count; j++) + { + if (i == j) continue; + if (Dominates(templates[j], templates[i])) + { + dominated = true; + break; + } + } + if (!dominated) kept.Add(templates[i]); + } + return kept; + } + + private static bool Dominates(PalletTemplate t, PalletTemplate u) + { + if (t.TotalHeight > u.TotalHeight) return false; + if (t.TotalWeight > u.TotalWeight) return false; + + bool strict = t.TotalHeight < u.TotalHeight || t.TotalWeight < u.TotalWeight; + + foreach (var (sku, uCount) in u.SkuCounts) + { + int tCount = t.SkuCounts.GetValueOrDefault(sku); + if (tCount < uCount) return false; + if (tCount > uCount) strict = true; + } + + if (!strict) + { + foreach (var sku in t.SkuCounts.Keys) + { + if (!u.SkuCounts.ContainsKey(sku)) + { + strict = true; + break; + } + } + } + + return strict; + } + } +} diff --git a/Styles/SnackbarStyle.xaml b/Styles/SnackbarStyle.xaml new file mode 100644 index 0000000..aa5289a --- /dev/null +++ b/Styles/SnackbarStyle.xaml @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ViewModels/Pages/LayerAnalyzerViewModel.cs b/ViewModels/Pages/LayerAnalyzerViewModel.cs index cc7d090..9613d74 100644 --- a/ViewModels/Pages/LayerAnalyzerViewModel.cs +++ b/ViewModels/Pages/LayerAnalyzerViewModel.cs @@ -8,6 +8,8 @@ using System.Text; using System.Windows.Input; using System.Windows.Media.Media3D; +using Wpf.Ui; +using Wpf.Ui.Controls; namespace Stack_Solver.ViewModels.Pages { @@ -16,6 +18,7 @@ public partial class LayerAnalyzerViewModel : ObservableObject private readonly IEventAggregator _events; private readonly LayerSceneBuilder _sceneBuilder = new(); private readonly ILayerVisualizationService _viz; + private readonly ISnackbarService _snackbarService; private CancellationTokenSource? _sceneBuildCts; private CancellationTokenSource? _generationCts; @@ -63,10 +66,11 @@ public bool CanOptimizeGeometry private Layer? _optimizedViewLayer; private static List? _allLayers = []; - public LayerAnalyzerViewModel(IEventAggregator events, ILayerVisualizationService viz) + public LayerAnalyzerViewModel(IEventAggregator events, ILayerVisualizationService viz, ISnackbarService snackbarService) { _events = events; _viz = viz; + _snackbarService = snackbarService; _events.Subscribe(OnSettingsChanged); ZoomCommand = new RelayCommand(Zoom); BeginPanCommand = new RelayCommand(BeginPan); @@ -204,6 +208,7 @@ private async Task Generate() if (_selectedSkus.Count == 0) { OutputText = "No SKUs selected with quantity greater than 0."; + _snackbarService.Show("Generation failed", "No SKUs selected with quantity > 0.", ControlAppearance.Danger, null, TimeSpan.FromSeconds(5)); return; } @@ -224,6 +229,7 @@ private async Task Generate() if (_allLayers == null || _allLayers.Count == 0) { OutputText = "No layers generated."; + _snackbarService.Show("Generation failed", "No layers were generated.", ControlAppearance.Danger, null, TimeSpan.FromSeconds(5)); return; } @@ -245,7 +251,7 @@ private async Task Generate() OutputText = "No layers after filtering."; } - LayerGenStats = $"Generated {_allLayers.Count} candidate layers using BLF, Homogeneous, StripFill, Radial{(_useCpsat ? ", CPSAT" : "")}."; + LayerGenStats = $"Generated {_allLayers.Count} candidate layers using BLF, Homogeneous, StripFill, Radial{(_useCpsat ? ", CPSAT" : "")}. Showing top 10 by utilization."; _events.Publish(new LayersGeneratedMessage(_allLayers)); } @@ -256,6 +262,7 @@ private async Task Generate() catch (Exception ex) { OutputText = $"Error: {ex.Message}"; + _snackbarService.Show("Generation failed", ex.Message, ControlAppearance.Danger, null, TimeSpan.FromSeconds(5)); } finally { diff --git a/ViewModels/Pages/PalletAnalyzerViewModel.cs b/ViewModels/Pages/PalletAnalyzerViewModel.cs index 676e2fc..ab8840c 100644 --- a/ViewModels/Pages/PalletAnalyzerViewModel.cs +++ b/ViewModels/Pages/PalletAnalyzerViewModel.cs @@ -5,9 +5,13 @@ using Stack_Solver.Models.Layering; using Stack_Solver.Models.Supports; using Stack_Solver.Services; +using Stack_Solver.Services.Stacking; +using Wpf.Ui; +using Wpf.Ui.Controls; using System.Collections.ObjectModel; using System.Text; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Media.Media3D; namespace Stack_Solver.ViewModels.Pages @@ -31,7 +35,7 @@ public partial class PalletAnalyzerViewModel : ObservableObject private bool _isBuilding; [ObservableProperty] - private string _outputText = "Click 'Generate' to start."; + private string _palletGenStats = "Click on 'Generate' to start solution generation."; [ObservableProperty] private ObservableCollection _assignments = []; @@ -51,15 +55,29 @@ public partial class PalletAnalyzerViewModel : ObservableObject [ObservableProperty] private ObservableCollection _solutions = []; + [ObservableProperty] + private SolutionDisplay? _selectedSolution; + + [ObservableProperty] + private string _assignmentDetailsText = string.Empty; + + [ObservableProperty] + private LayerTypeDisplay? _selectedLayerType; + + private readonly List _layerHighlights = []; + public Model3DGroup Scene { get; } = new(); public ViewportController? ViewportController => _viewportController; public ICommand ZoomCommand { get; } public ICommand BeginPanCommand { get; } public ICommand PanCommand { get; } - public PalletAnalyzerViewModel(IEventAggregator events) + private readonly ISnackbarService _snackbarService; + + public PalletAnalyzerViewModel(IEventAggregator events, ISnackbarService snackbarService) { _events = events; + _snackbarService = snackbarService; _events.Subscribe(OnLayersGenerated); _events.Subscribe(OnSettingsChanged); ZoomCommand = new RelayCommand(delta => _viewportController?.Zoom(delta)); @@ -90,7 +108,6 @@ private void OnLayersGenerated(LayersGeneratedMessage msg) HasResults = false; Assignments.Clear(); Solutions.Clear(); - OutputText = $"{_availableLayers.Count} candidate layers ready. Building pallets..."; _buildCts?.Cancel(); _buildCts?.Dispose(); @@ -108,15 +125,14 @@ private void OnSettingsChanged(SettingsChangedMessage msg) _maxSkuOverhang = msg.MaxSkuOverhang; _selectedSkus = [.. msg.Skus.Where(s => s.IsSelected && s.Quantity > 0)]; _generationOptions = new GenerationOptions(msg.SolverTimeLimit, msg.MaxCpsatCandidates, msg.BlfAttempts); - if (_viewportController != null) - _viewportController.Target = CurrentPalletCenter; + _viewportController?.Target = CurrentPalletCenter; } private async Task BuildPalletsAsync(CancellationToken ct) { if (_availableLayers.Count == 0 || _selectedSkus.Count == 0) { - OutputText = _availableLayers.Count == 0 + PalletGenStats = _availableLayers.Count == 0 ? "No layers available." : "No SKUs selected with quantity > 0."; return; @@ -136,51 +152,68 @@ private async Task BuildPalletsAsync(CancellationToken ct) var skus = _selectedSkus.ToList(); var layersSnapshot = _availableLayers.ToList(); - var result = await Task.Run(() => + var greedyResult = await Task.Run(() => { ct.ThrowIfCancellationRequested(); var filtered = LayerMetricsCalculator.FilterLayers(layersSnapshot, options); - return GreedyAssignmentService.Assign(filtered, demand, pallet); + var greedy = GreedyAssignmentService.Assign(filtered, demand, pallet); + if (!greedy.HasLeftovers) return greedy; + + var leftoverSkus = BuildLeftoverSkus(skus, greedy.Leftovers); + if (leftoverSkus.Count == 0) return greedy; + var tailLayers = LayerGenerator.Generate(leftoverSkus, pallet, options, ct: ct); + if (tailLayers.Count == 0) return greedy; + var tailOptions = new GenerationOptions(options.MaxSolverTime, options.MaxCPSATCandidates, options.BLFAttempts) + { + MaxLayerStability = options.MaxLayerStability, + PerSkuTopLayerFraction = 1.0 + }; + var tailFiltered = LayerMetricsCalculator.FilterLayers(tailLayers, tailOptions); + if (tailFiltered.Count == 0) return greedy; + return MergeResults(greedy, GreedyAssignmentService.Assign(tailFiltered, greedy.Leftovers, pallet)); }, ct); ct.ThrowIfCancellationRequested(); - if (result.HasLeftovers) + AssignmentResult? cpsatResult = null; + try { - var tailInput = result; - result = await Task.Run(() => + cpsatResult = await Task.Run(() => { - var leftoverSkus = BuildLeftoverSkus(skus, tailInput.Leftovers); - if (leftoverSkus.Count == 0) return tailInput; - var tailLayers = LayerGenerator.Generate(leftoverSkus, pallet, options, ct: ct); - if (tailLayers.Count == 0) return tailInput; - var tailOptions = new GenerationOptions(options.MaxSolverTime, options.MaxCPSATCandidates, options.BLFAttempts) - { - MaxLayerStability = options.MaxLayerStability, - PerSkuTopLayerFraction = 1.0 - }; - var filtered = LayerMetricsCalculator.FilterLayers(tailLayers, tailOptions); - if (filtered.Count == 0) return tailInput; - return MergeResults(tailInput, GreedyAssignmentService.Assign(filtered, tailInput.Leftovers, pallet)); + ct.ThrowIfCancellationRequested(); + var filtered = LayerMetricsCalculator.FilterLayers(layersSnapshot, options); + var pool = PalletTemplateEnumerator.Enumerate(pallet, filtered); + var pruned = TemplateFilter.Filter(pool, pallet); + return CPSATAssignmentService.Assign(pruned, demand, pallet, options, greedyResult, ct); }, ct); } + catch (OperationCanceledException) { throw; } + catch { /* CP-SAT is best-effort; greedy result still shown */ } ct.ThrowIfCancellationRequested(); - int index = 1; - foreach (var (template, count) in result.Assignments) - Assignments.Add(new TemplateAssignmentDisplay(template, count, skus, index++, _palletLength, _palletWidth, _palletHeight)); + Solutions.Clear(); + if (cpsatResult != null && cpsatResult.Assignments.Count > 0) + Solutions.Add(new SolutionDisplay(Solutions.Count + 1, "CP-SAT", cpsatResult, skus, _palletLength, _palletWidth, _palletHeight)); + if (greedyResult.Assignments.Count > 0) + Solutions.Add(new SolutionDisplay(Solutions.Count + 1, "Greedy", greedyResult, skus, _palletLength, _palletWidth, _palletHeight)); - HasResults = Assignments.Count > 0; - SelectedAssignment = Assignments.FirstOrDefault(); + HasResults = Solutions.Count > 0; if (HasResults) - Solutions.Add(new SolutionDisplay(1, result)); - OutputText = BuildSummaryText(result, skus); + { + SelectedSolution = Solutions[0]; + PalletGenStats = $"Generated {Solutions.Count} solutions."; + _snackbarService.Show("Generation complete", + $"{_availableLayers.Count} candidate layers and {Solutions.Count} final {(Solutions.Count == 1 ? "solution" : "solutions")} generated.", + ControlAppearance.Success, null, TimeSpan.FromSeconds(5)); + } + else + PalletGenStats = "No assignments produced."; } catch (OperationCanceledException) { } catch (Exception ex) { - OutputText = $"Error: {ex.Message}"; + PalletGenStats = $"Error: {ex.Message}"; } finally { @@ -188,12 +221,69 @@ private async Task BuildPalletsAsync(CancellationToken ct) } } + partial void OnSelectedSolutionChanged(SolutionDisplay? value) + { + Assignments.Clear(); + + if (value == null) + { + SelectedAssignment = null; + return; + } + + int idx = 1; + foreach (var (template, count) in value.Result.Assignments) + Assignments.Add(new TemplateAssignmentDisplay(template, count, value.Skus, idx++, value.PalletLength, value.PalletWidth, value.PalletHeight)); + + SelectedAssignment = Assignments.FirstOrDefault(); + } + + partial void OnSelectedLayerTypeChanged(LayerTypeDisplay? value) + { + foreach (var h in _layerHighlights) + Scene.Children.Remove(h); + _layerHighlights.Clear(); + + if (value == null) return; + + double inflate = 0.1; + var fillBrush = new SolidColorBrush(Color.FromArgb(40, 255, 255, 0)); + + foreach (var (layerId, y, height) in _sceneBuilder.LayerPositions) + { + if (layerId != value.LayerId) continue; + var origin = new Point3D(-inflate / 2.0, y - inflate / 2.0, -inflate / 2.0); + var highlight = GeometryCreator.CreateBoxWithEdges( + origin, + _palletLength + inflate, + height + inflate, + _palletWidth + inflate, + fillBrush, + Colors.Yellow, + 0.6); + Scene.Children.Add(highlight); + _layerHighlights.Add(highlight); + } + } + + public bool TryGetLayerTypeForGeometry(GeometryModel3D geo, out LayerTypeDisplay? layerType) + { + layerType = null; + if (!_sceneBuilder.TryGetLayerIdForGeometry(geo, out var layerId)) return false; + layerType = SelectedLayerTypes.FirstOrDefault(lt => lt.LayerId == layerId); + return layerType != null; + } + partial void OnSelectedAssignmentChanged(TemplateAssignmentDisplay? value) { + SelectedLayerType = null; + SelectedLayerTypes = value != null ? new ObservableCollection(value.LayerTypes) : []; + AssignmentDetailsText = value != null ? BuildAssignmentText(value) : string.Empty; + if (value != null && _viewportController != null) { double totalHeight = _palletHeight + value.Template.TotalHeight; @@ -255,23 +345,24 @@ private static List BuildLeftoverSkus(List originalSkus, IReadOnlyDict })]; } - private static string BuildSummaryText(AssignmentResult result, List skus) + private string BuildAssignmentText(TemplateAssignmentDisplay assignment) { - var skuMap = skus.ToDictionary(s => s.SkuId, s => s.Name, StringComparer.Ordinal); + var t = assignment.Template; + int cargoHeight = (int)Math.Round(t.TotalHeight); + int totalHeight = _palletHeight + cargoHeight; + var sb = new StringBuilder(); - sb.AppendLine($"Total pallets: {result.TotalPallets}"); - sb.AppendLine($"Distinct templates: {result.Assignments.Count}"); - //if (result.HasLeftovers) - //{ - // sb.AppendLine(); - // sb.AppendLine("Leftover boxes:"); - // foreach (var (skuId, count) in result.Leftovers) - // sb.AppendLine($" {skuMap.GetValueOrDefault(skuId, skuId)}: {count}"); - //} - //else - //{ - // sb.AppendLine("All boxes packed."); - //} + sb.AppendLine($"{assignment.Name} (x{assignment.Count} {(assignment.Count == 1 ? "pallet" : "pallets")})"); + sb.AppendLine(); + sb.AppendLine($"Load dimensions: {_palletLength}x{_palletWidth}x{totalHeight} cm"); + sb.AppendLine($"Cargo height: {cargoHeight} cm"); + sb.AppendLine($"Weight: {t.TotalWeight:N0} kg"); + sb.AppendLine($"Boxes: {t.TotalBoxCount} per pallet"); + sb.AppendLine($"Utilization: {t.AverageLayerUtilization:F3}"); + sb.AppendLine($"Contents: {assignment.Contents}"); + sb.AppendLine(); + sb.AppendLine("=================="); + sb.AppendLine("Full details are included in the PDF report."); return sb.ToString(); } } @@ -312,6 +403,7 @@ public TemplateAssignmentDisplay(PalletTemplate template, int count, IEnumerable public class LayerTypeDisplay(Layer layer, int count, IReadOnlyDictionary skuNames) { + public string LayerId { get; } = layer.Id; public string Name { get; } = layer.Name; public int Count { get; } = count; public string Contents { get; } = string.Join(", ", layer.Items @@ -320,20 +412,36 @@ public class LayerTypeDisplay(Layer layer, int count, IReadOnlyDictionary Skus { get; } + public int PalletLength { get; } + public int PalletWidth { get; } + public int PalletHeight { get; } public int TotalPallets { get; } public int PalletTypes { get; } public int TotalItemsPacked { get; } public string Efficiency { get; } - public bool IsActive { get; set; } = true; - public SolutionDisplay(int number, AssignmentResult result) + public SolutionDisplay( + int number, + string name, + AssignmentResult result, + IReadOnlyList skus, + int palletLength, + int palletWidth, + int palletHeight) { Number = number; - Name = "Greedy"; + Name = name; + Result = result; + Skus = skus; + PalletLength = palletLength; + PalletWidth = palletWidth; + PalletHeight = palletHeight; TotalPallets = result.TotalPallets; PalletTypes = result.Assignments.Count; TotalItemsPacked = result.Assignments.Sum(a => a.Template.TotalBoxCount * a.Count); diff --git a/ViewModels/Pages/PalletBuilderViewModel.cs b/ViewModels/Pages/PalletBuilderViewModel.cs index a2a9010..99ad1b2 100644 --- a/ViewModels/Pages/PalletBuilderViewModel.cs +++ b/ViewModels/Pages/PalletBuilderViewModel.cs @@ -5,6 +5,7 @@ using Stack_Solver.Models; using Stack_Solver.Models.Inputs; using Stack_Solver.Services; +using Wpf.Ui; namespace Stack_Solver.ViewModels.Pages { @@ -16,11 +17,12 @@ public partial class PalletBuilderViewModel( IOptions palletDefaults, IValidator settingsValidator, IValidator skuQuantityValidator, - IUserSettingsService userSettings) : ObservableObject + IUserSettingsService userSettings, + ISnackbarService snackbarService) : ObservableObject { public PalletBuilderSettingsViewModel Settings { get; } = new PalletBuilderSettingsViewModel(skuRepository, events, genOptions, palletDefaults, settingsValidator, skuQuantityValidator, userSettings); - public LayerAnalyzerViewModel LayerAnalyzer { get; } = new LayerAnalyzerViewModel(events, viz); - public PalletAnalyzerViewModel PalletAnalyzer { get; } = new PalletAnalyzerViewModel(events); + public LayerAnalyzerViewModel LayerAnalyzer { get; } = new LayerAnalyzerViewModel(events, viz, snackbarService); + public PalletAnalyzerViewModel PalletAnalyzer { get; } = new PalletAnalyzerViewModel(events, snackbarService); public async Task OnNavigatedToAsync() { diff --git a/Views/Pages/PalletBuilderPage.xaml b/Views/Pages/PalletBuilderPage.xaml index d7aee5c..17d0489 100644 --- a/Views/Pages/PalletBuilderPage.xaml +++ b/Views/Pages/PalletBuilderPage.xaml @@ -64,7 +64,7 @@ - + @@ -169,6 +169,15 @@ + + + + + + + @@ -185,12 +194,6 @@ Minimum="1" IsEnabled="{Binding ViewModel.Settings.UseCpsat}" SmallChange="100" LargeChange="1000"/> - - - @@ -233,21 +236,23 @@ - - - - - - - - - - - + + + + + + + + + + + + + @@ -260,26 +265,32 @@ + - + - + + + + + + - + + - - @@ -310,7 +319,8 @@ HeadersVisibility="Column" ScrollViewer.VerticalScrollBarVisibility="Auto" BorderThickness="0" - ItemsSource="{Binding ViewModel.PalletAnalyzer.SelectedLayerTypes}"> + ItemsSource="{Binding ViewModel.PalletAnalyzer.SelectedLayerTypes}" + SelectedItem="{Binding ViewModel.PalletAnalyzer.SelectedLayerType, Mode=TwoWay}"> @@ -320,15 +330,23 @@ - - - + + + + + + + + + + + ItemsSource="{Binding ViewModel.PalletAnalyzer.Solutions}" + SelectedItem="{Binding ViewModel.PalletAnalyzer.SelectedSolution, Mode=TwoWay}"> @@ -336,28 +354,29 @@ - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/Views/Pages/PalletBuilderPage.xaml.cs b/Views/Pages/PalletBuilderPage.xaml.cs index 2c5ef97..5677b29 100644 --- a/Views/Pages/PalletBuilderPage.xaml.cs +++ b/Views/Pages/PalletBuilderPage.xaml.cs @@ -20,7 +20,8 @@ public PalletBuilderPage(PalletBuilderViewModel viewModel) DataContext = this; InitializeComponent(); Loaded += OnLoaded; - MainViewPort.MouseLeftButtonDown += MainViewPort_MouseLeftButtonDown; + MainViewPortHost.MouseLeftButtonDown += MainViewPort_MouseLeftButtonDown; + PalletViewPortHost.MouseLeftButtonDown += PalletViewPort_MouseLeftButtonDown; } private async void OnLoaded(object? sender, RoutedEventArgs e) @@ -52,6 +53,28 @@ HitTestResultBehavior resultCallback(HitTestResult r) ViewModel.LayerAnalyzer.UpdateSelectedItem(selected); } + private void PalletViewPort_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + var pos = e.GetPosition(PalletViewPort); + var hitParams = new PointHitTestParameters(pos); + LayerTypeDisplay? found = null; + + HitTestResultBehavior resultCallback(HitTestResult r) + { + if (r is RayHitTestResult rayResult + && rayResult.ModelHit is GeometryModel3D geo + && ViewModel.PalletAnalyzer.TryGetLayerTypeForGeometry(geo, out var layerType)) + { + found = layerType; + return HitTestResultBehavior.Stop; + } + return HitTestResultBehavior.Continue; + } + + VisualTreeHelper.HitTest(PalletViewPort, null, resultCallback, hitParams); + ViewModel.PalletAnalyzer.SelectedLayerType = found; + } + private async void SkuSelectionGrid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e) { if (e.EditAction != DataGridEditAction.Commit) diff --git a/Views/Pages/SKULibraryPage.xaml.cs b/Views/Pages/SKULibraryPage.xaml.cs index b098593..de8de22 100644 --- a/Views/Pages/SKULibraryPage.xaml.cs +++ b/Views/Pages/SKULibraryPage.xaml.cs @@ -23,7 +23,7 @@ public SKULibraryPage(SKULibraryViewModel viewModel) private void skuDataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e) { // Cancel generation of hidden columns - if (e.PropertyName == nameof(SKU.SkuId) || e.PropertyName == nameof(SKU.Quantity)) + if (e.PropertyName == nameof(SKU.SkuId) || e.PropertyName == nameof(SKU.Quantity) || e.PropertyName == nameof(SKU.IsSelected)) { e.Cancel = true; return; diff --git a/Views/Windows/MainWindow.xaml b/Views/Windows/MainWindow.xaml index bad9399..f3feb94 100644 --- a/Views/Windows/MainWindow.xaml +++ b/Views/Windows/MainWindow.xaml @@ -7,8 +7,8 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="{Binding ViewModel.ApplicationTitle, Mode=OneWay}" - Width="1100" - Height="650" + Width="1400" + Height="800" d:DataContext="{d:DesignInstance local:MainWindow, IsDesignTimeCreatable=True}" d:DesignHeight="450" diff --git a/Views/Windows/MainWindow.xaml.cs b/Views/Windows/MainWindow.xaml.cs index 1d0f6fc..2e8311a 100644 --- a/Views/Windows/MainWindow.xaml.cs +++ b/Views/Windows/MainWindow.xaml.cs @@ -14,7 +14,8 @@ public partial class MainWindow : INavigationWindow public MainWindow( MainWindowViewModel viewModel, INavigationViewPageProvider navigationViewPageProvider, - INavigationService navigationService + INavigationService navigationService, + ISnackbarService snackbarService ) { ViewModel = viewModel; @@ -26,6 +27,7 @@ INavigationService navigationService SetPageService(navigationViewPageProvider); navigationService.SetNavigationControl(RootNavigation); + snackbarService.SetSnackbarPresenter(SnackbarPresenter); RootNavigation.Navigated += OnRootNavigationNavigated; }