diff --git a/App.xaml.cs b/App.xaml.cs index 8e8133b..96bc8b6 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -42,7 +42,8 @@ public partial class App .ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory)!); - c.AddJsonFile("defaults.json", optional: true, reloadOnChange: true); + c.AddJsonFile("defaults.json", optional: true, reloadOnChange: false); + c.AddJsonFile(AppPaths.UserSettingsFile, optional: true, reloadOnChange: false); }) .ConfigureServices((context, services) => { @@ -70,6 +71,8 @@ public partial class App services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new PalletBuilderViewModel( sp.GetRequiredService(), sp.GetRequiredService(), @@ -77,7 +80,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/Data/AppPaths.cs b/Data/AppPaths.cs index e5205d9..5b37c52 100644 --- a/Data/AppPaths.cs +++ b/Data/AppPaths.cs @@ -9,6 +9,8 @@ public static class AppPaths public static readonly string DatabaseFile = Path.Combine(AppDataDirectory, "stacksolver.db"); + public static readonly string UserSettingsFile = Path.Combine(AppDataDirectory, "user-settings.json"); + public static void EnsureAppData() { if (!Directory.Exists(AppDataDirectory)) diff --git a/Helpers/Rendering/PalletSceneBuilder.cs b/Helpers/Rendering/PalletSceneBuilder.cs new file mode 100644 index 0000000..3fded59 --- /dev/null +++ b/Helpers/Rendering/PalletSceneBuilder.cs @@ -0,0 +1,88 @@ +using Stack_Solver.Models.Supports; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace Stack_Solver.Helpers.Rendering +{ + public class PalletSceneBuilder + { + private readonly Dictionary _skuBrushCache = []; + private readonly Lock _cacheLock = new(); + + public async Task BuildAsync( + Model3DGroup target, + PalletTemplate template, + int palletLength, int palletWidth, double palletHeight, + CancellationToken ct = default) + { + if (target == null || template == null) return; + + var tempGroup = await Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + var g = new Model3DGroup(); + + g.Children.Add(new AmbientLight(Colors.DimGray)); + g.Children.Add(new DirectionalLight(Colors.White, new Vector3D(-1, -2, -1))); + + var palletBrush = new SolidColorBrush(Color.FromRgb(160, 120, 80)); + palletBrush.Freeze(); + g.Children.Add(GeometryCreator.CreateBoxWithEdges( + new Point3D(0, 0, 0), palletLength, palletHeight, palletWidth, + palletBrush, Colors.Black, 0.4)); + + double currentY = palletHeight; + foreach (var layer in template.Layers) + { + ct.ThrowIfCancellationRequested(); + + foreach (var item in layer.Items) + { + var sku = item.SkuType; + double boxLength = item.Rotated ? sku.Width : sku.Length; + 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)); + } + + currentY += layer.Metadata.Height; + } + + TryFreezeRecursive(g); + return g; + }, ct).ConfigureAwait(true); + + ct.ThrowIfCancellationRequested(); + target.Children.Clear(); + foreach (var child in tempGroup.Children) + target.Children.Add(child); + } + + private Brush GetBrushForSku(string skuId) + { + lock (_cacheLock) + { + if (_skuBrushCache.TryGetValue(skuId, out var b)) return b; + int hash = skuId.GetHashCode(); + byte r = (byte)(50 + (hash & 0x7F)); + byte g = (byte)(50 + ((hash >> 7) & 0x7F)); + byte bl = (byte)(50 + ((hash >> 14) & 0x7F)); + var brush = new SolidColorBrush(Color.FromRgb(r, g, bl)); + if (brush.CanFreeze) brush.Freeze(); + _skuBrushCache[skuId] = brush; + return brush; + } + } + + private static void TryFreezeRecursive(Model3D model) + { + if (model is Model3DGroup group) + foreach (var child in group.Children) + TryFreezeRecursive(child); + if (model is Freezable f && f.CanFreeze && !f.IsFrozen) + try { f.Freeze(); } catch { } + } + } +} diff --git a/Helpers/Rendering/ViewportController.cs b/Helpers/Rendering/ViewportController.cs index 2c73254..94e39cd 100644 --- a/Helpers/Rendering/ViewportController.cs +++ b/Helpers/Rendering/ViewportController.cs @@ -62,6 +62,15 @@ public void Pan(Point current) UpdateCameraPosition(); } + public void ResetView(Point3D target, double distance) + { + Target = target; + Distance = distance; + Azimuth = Math.PI / 4; + Elevation = Math.PI / 6; + UpdateCameraPosition(); + } + private void UpdateCameraPosition() { double x = Target.X + Distance * Math.Cos(Elevation) * Math.Sin(Azimuth); diff --git a/Infrastructure/UserSettingsService.cs b/Infrastructure/UserSettingsService.cs new file mode 100644 index 0000000..3072e1b --- /dev/null +++ b/Infrastructure/UserSettingsService.cs @@ -0,0 +1,25 @@ +using Stack_Solver.Data; +using Stack_Solver.Models; +using System.IO; +using System.Text.Json; + +namespace Stack_Solver.Infrastructure +{ + public interface IUserSettingsService + { + Task SaveAsync(PalletDefaultsOptions palletDefaults, GenerationOptions genOptions); + } + + public class UserSettingsService : IUserSettingsService + { + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + public async Task SaveAsync(PalletDefaultsOptions palletDefaults, GenerationOptions genOptions) + { + AppPaths.EnsureAppData(); + var data = new { LayerGeneration = genOptions, PalletDefaults = palletDefaults }; + var json = JsonSerializer.Serialize(data, _jsonOptions); + await File.WriteAllTextAsync(AppPaths.UserSettingsFile, json); + } + } +} diff --git a/Models/Assignment/AssignmentResult.cs b/Models/Assignment/AssignmentResult.cs new file mode 100644 index 0000000..44b947b --- /dev/null +++ b/Models/Assignment/AssignmentResult.cs @@ -0,0 +1,13 @@ +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Models.Assignment +{ + public class AssignmentResult + { + public IReadOnlyList<(PalletTemplate Template, int Count)> Assignments { get; init; } = []; + public IReadOnlyDictionary Leftovers { get; init; } = new Dictionary(); + + public int TotalPallets => Assignments.Sum(a => a.Count); + public bool HasLeftovers => Leftovers.Values.Any(v => v > 0); + } +} diff --git a/Models/SKU.cs b/Models/SKU.cs index e24febe..a651ad7 100644 --- a/Models/SKU.cs +++ b/Models/SKU.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace Stack_Solver.Models { @@ -20,6 +21,9 @@ public class SKU public int Quantity { get; set; } = 0; + [NotMapped] + public bool IsSelected { get; set; } = false; + public SKU(string skuId, string name, int length, int width, int height, double weight, bool rotatable, string notes) { SkuId = skuId; diff --git a/Models/Supports/Pallet.cs b/Models/Supports/Pallet.cs index 28312e9..8db3c46 100644 --- a/Models/Supports/Pallet.cs +++ b/Models/Supports/Pallet.cs @@ -12,8 +12,11 @@ namespace Stack_Solver.Models.Supports /// The height of the pallet. public class Pallet(string name, int length, int width, int height) : SupportSurface(name, length, width, height) { - List Layers { get; } = []; + public int MaxStackHeight { get; init; } = 180; + public int MaxStackWeight { get; init; } = 950; + public double MaxSkuOverhang { get; init; } = 0; - PalletMetadata Metadata { get; set; } = new(); + public List Layers { get; } = []; + public PalletMetadata Metadata { get; set; } = new(); } } diff --git a/Models/Supports/PalletTemplate.cs b/Models/Supports/PalletTemplate.cs new file mode 100644 index 0000000..13fa1b4 --- /dev/null +++ b/Models/Supports/PalletTemplate.cs @@ -0,0 +1,33 @@ +using Stack_Solver.Models.Layering; + +namespace Stack_Solver.Models.Supports +{ + public class PalletTemplate + { + public string Id { get; } = Guid.NewGuid().ToString(); + public IReadOnlyList Layers { get; init; } = []; + public double TotalHeight { get; init; } + public double TotalWeight { get; init; } + public IReadOnlyDictionary SkuCounts { get; init; } = new Dictionary(); + public int TotalBoxCount { get; init; } + public double AverageLayerUtilization { get; init; } + + public static PalletTemplate FromLayers(IReadOnlyList layers) + { + var skuCounts = new Dictionary(StringComparer.Ordinal); + foreach (var layer in layers) + foreach (var item in layer.Items) + skuCounts[item.SkuType.SkuId] = skuCounts.GetValueOrDefault(item.SkuType.SkuId) + 1; + + return new PalletTemplate + { + Layers = layers, + TotalHeight = layers.Sum(l => l.Metadata.Height), + TotalWeight = layers.Sum(l => l.Metrics.TotalWeight), + SkuCounts = skuCounts, + TotalBoxCount = layers.Sum(l => l.Items.Count), + AverageLayerUtilization = layers.Count > 0 ? layers.Average(l => l.Metadata.Utilization) : 0 + }; + } + } +} diff --git a/Properties/Settings.Designer.cs b/Properties/Settings.Designer.cs new file mode 100644 index 0000000..7901f82 --- /dev/null +++ b/Properties/Settings.Designer.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Stack_Solver.Properties { + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.6.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default { + get { + return defaultInstance; + } + } + } +} diff --git a/Properties/Settings.settings b/Properties/Settings.settings new file mode 100644 index 0000000..049245f --- /dev/null +++ b/Properties/Settings.settings @@ -0,0 +1,6 @@ + + + + + + diff --git a/Services/GreedyAssignmentService.cs b/Services/GreedyAssignmentService.cs new file mode 100644 index 0000000..93f9a1f --- /dev/null +++ b/Services/GreedyAssignmentService.cs @@ -0,0 +1,137 @@ +using Stack_Solver.Models.Assignment; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services +{ + /// + /// Builds pallets one layer at a time (Strategy B from the greedy palletization plan). + /// At each step the highest-scoring candidate layer is selected from the pool: it must + /// fit within height/weight limits and must not exceed remaining per-SKU demand. + /// Opens new pallets until demand is exhausted or no layer can be placed. + /// Identical pallets (same layer sequence) are grouped in the output. + /// + public static class GreedyAssignmentService + { + public static AssignmentResult Assign( + IReadOnlyList layers, + IReadOnlyDictionary demand, + Pallet pallet) + { + var remaining = new Dictionary(demand, StringComparer.Ordinal); + var builtPallets = new List(); + + while (remaining.Values.Any(v => v > 0)) + { + var template = PackOnePallet(layers, remaining, pallet); + if (template == null) break; + builtPallets.Add(template); + } + + var assignments = builtPallets + .GroupBy(PalletSignature, StringComparer.Ordinal) + .Select(g => (Template: g.First(), Count: g.Count())) + .ToList(); + + var leftovers = remaining + .Where(kvp => kvp.Value > 0) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal); + + return new AssignmentResult { Assignments = assignments, Leftovers = leftovers }; + } + + private static PalletTemplate? PackOnePallet( + IReadOnlyList layers, + Dictionary remaining, + Pallet pallet) + { + var stackedLayers = new List(); + double usedHeight = pallet.Height; + double usedWeight = 0; + + while (true) + { + var best = SelectBestLayer(layers, stackedLayers, remaining, pallet, usedHeight, usedWeight); + if (best == null) break; + + foreach (var item in best.Items) + { + var skuId = item.SkuType.SkuId; + if (remaining.ContainsKey(skuId)) + remaining[skuId] = Math.Max(0, remaining[skuId] - 1); + } + + stackedLayers.Add(best); + usedHeight += best.Metadata.Height; + usedWeight += best.Metrics.TotalWeight; + } + + return stackedLayers.Count > 0 ? PalletTemplate.FromLayers(stackedLayers) : null; + } + + private static Layer? SelectBestLayer( + IReadOnlyList layers, + IReadOnlyList stackedLayers, + IReadOnlyDictionary remaining, + Pallet pallet, + double usedHeight, + double usedWeight) + { + Layer? best = null; + double bestScore = double.MinValue; + + foreach (var layer in layers) + { + if (!CanAddLayer(layer, stackedLayers, pallet, usedHeight, usedWeight)) continue; + if (!FitsWithinDemand(layer, remaining)) continue; + + double score = ScoreLayer(layer); + if (score > bestScore) + { + bestScore = score; + best = layer; + } + } + + return best; + } + + private static bool CanAddLayer( + Layer layer, + IReadOnlyList stackedLayers, + Pallet pallet, + double usedHeight, + double usedWeight) + { + if (usedHeight + layer.Metadata.Height > pallet.MaxStackHeight) return false; + if (usedWeight + layer.Metrics.TotalWeight > pallet.MaxStackWeight) return false; + + if (stackedLayers.Count > 0) + { + var support = LayerSupportAnalyzer.Analyze(stackedLayers[^1], layer, pallet); + if (support.MaximumSkuOverhangArea > pallet.MaxSkuOverhang) return false; + } + + return true; + } + + private static bool FitsWithinDemand(Layer layer, IReadOnlyDictionary remaining) + { + foreach (var g in layer.Items.GroupBy(i => i.SkuType.SkuId, StringComparer.Ordinal)) + { + if (!remaining.TryGetValue(g.Key, out int rem) || rem < g.Count()) + return false; + } + return true; + } + + private static double ScoreLayer(Layer layer) + { + // Maximize boxes packed per layer; break ties by utilization + return layer.Items.Count * 1000.0 + layer.Metrics.Utilization; + } + + private static string PalletSignature(PalletTemplate t) => + string.Join("|", t.Layers.Select(l => l.Id)); + } +} diff --git a/Services/ILayerStackingStrategy.cs b/Services/ILayerStackingStrategy.cs index 0330845..d3878f6 100644 --- a/Services/ILayerStackingStrategy.cs +++ b/Services/ILayerStackingStrategy.cs @@ -4,14 +4,9 @@ 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; } - SupportSurface Build(SupportSurface pallet, List layers, GenerationOptions options); + PalletTemplate? Build(SupportSurface pallet, IReadOnlyList layers, GenerationOptions options); } } diff --git a/Services/LayerGenerator.cs b/Services/LayerGenerator.cs new file mode 100644 index 0000000..e75be68 --- /dev/null +++ b/Services/LayerGenerator.cs @@ -0,0 +1,60 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.Layering; + +namespace Stack_Solver.Services +{ + public static class LayerGenerator + { + public static List Generate( + List skus, + SupportSurface pallet, + GenerationOptions options, + bool useCpsat = false, + CancellationToken ct = default) + { + var strategies = new List + { + new BLFGenerationStrategy(), + new HomogeneousGenerationStrategy(), + new StripFillGenerationStrategy(), + new RadialPlacementGenerationStrategy() + }; + if (useCpsat) + strategies.Add(new CPSATGenerationStrategy()); + + var aggregate = new List(); + foreach (var strat in strategies) + { + ct.ThrowIfCancellationRequested(); + try + { + var produced = strat.Generate(skus, pallet, options); + if (produced?.Count > 0) + aggregate.AddRange(produced); + } + catch (OperationCanceledException) { throw; } + catch { } + } + + var valid = aggregate + .Where(l => l?.Metadata != null && + !double.IsNaN(l.Metadata.Utilization) && + !double.IsInfinity(l.Metadata.Utilization) && + l.Metadata.Utilization > 0.0 && + l.Metadata.Utilization <= 1.0) + .ToList(); + + foreach (var layer in valid) + { + LayerGeometryOptimizer.CenterLayer(layer); + // Centering shifts item positions, so geometry grids and stability metrics must be recomputed. + layer.Geometry = LayerGeometryBuilder.Build(layer, pallet); + layer.Metrics = LayerMetricsCalculator.Compute(layer, pallet); + } + + return valid; + } + } +} diff --git a/Services/Layering/HomogeneousGenerationStrategy.cs b/Services/Layering/HomogeneousGenerationStrategy.cs index fe59a13..d703668 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.SkuId}_{orientation}", placements, metadata); + var layer = new Layer($"hom_grid_{variant.Sku.Name}", placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); return layer; diff --git a/Services/Stacking/GreedyStackingStrategy.cs b/Services/Stacking/GreedyStackingStrategy.cs new file mode 100644 index 0000000..a4087e5 --- /dev/null +++ b/Services/Stacking/GreedyStackingStrategy.cs @@ -0,0 +1,64 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services.Stacking +{ + /// + /// Builds a single pallet template by greedily stacking layers in the order provided, + /// skipping any layer that violates height, weight, or inter-layer support constraints. + /// The first layer always sits on the flat pallet surface (no support check needed). + /// Each subsequent layer is checked via LayerSupportAnalyzer: any item whose unsupported + /// footprint area exceeds MaxSkuOverhang causes the layer to be skipped. + /// + public class GreedyStackingStrategy : ILayerStackingStrategy + { + public string Name => "Greedy"; + + public PalletTemplate? Build(SupportSurface pallet, IReadOnlyList layers, GenerationOptions options) + { + if (layers.Count == 0) + return null; + + var stackedLayers = new List(); + double usedHeight = pallet.Height; + double usedWeight = 0; + + int maxHeight = pallet is Pallet p ? p.MaxStackHeight : int.MaxValue; + int maxWeight = pallet is Pallet p2 ? p2.MaxStackWeight : int.MaxValue; + double maxSkuOverhang = pallet is Pallet p3 ? p3.MaxSkuOverhang : 0; + + // After each successful placement, restart from the top of the ordered list. + // This lets the same layer pattern repeat as many times as the height/weight + // limits allow (e.g. five identical homogeneous layers stacked on each other). + bool anyAdded; + do + { + anyAdded = false; + foreach (var layer in layers) + { + if (usedHeight + layer.Metadata.Height > maxHeight) continue; + if (usedWeight + layer.Metrics.TotalWeight > maxWeight) continue; + + // First layer sits on the flat pallet surface — always supported. + // Every subsequent layer must be supported by the current top layer. + // A layer trivially supports itself (identical item positions), so + // same-pattern repetition always passes this check with MaxSkuOverhang=0. + if (stackedLayers.Count > 0) + { + var support = LayerSupportAnalyzer.Analyze(stackedLayers[^1], layer, pallet); + if (support.MaximumSkuOverhangArea > maxSkuOverhang) continue; + } + + stackedLayers.Add(layer); + usedHeight += layer.Metadata.Height; + usedWeight += layer.Metrics.TotalWeight; + anyAdded = true; + break; + } + } while (anyAdded); + + return stackedLayers.Count > 0 ? PalletTemplate.FromLayers(stackedLayers) : null; + } + } +} diff --git a/Services/Stacking/PalletTemplateEnumerator.cs b/Services/Stacking/PalletTemplateEnumerator.cs new file mode 100644 index 0000000..385c661 --- /dev/null +++ b/Services/Stacking/PalletTemplateEnumerator.cs @@ -0,0 +1,67 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; + +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. + /// + public static class PalletTemplateEnumerator + { + private static readonly GreedyStackingStrategy _strategy = new(); + + public static List Enumerate( + Pallet pallet, + IReadOnlyList layers, + GenerationOptions options) + { + if (layers.Count == 0) + return []; + + var templates = new List(); + + // Ordering 1: heaviest layers first (plan §5: heavy on bottom) + TryAdd(pallet, layers.OrderByDescending(l => l.Metrics.TotalWeight).ToList(), options, templates); + + // Ordering 2: highest utilization first + TryAdd(pallet, layers.OrderByDescending(l => l.Metadata.Utilization).ToList(), options, templates); + + // Ordering 3: each layer as mandatory base, rest sorted by weight + foreach (var baseLayer in layers) + { + var ordered = layers + .Where(l => !ReferenceEquals(l, baseLayer)) + .OrderByDescending(l => l.Metrics.TotalWeight) + .Prepend(baseLayer) + .ToList(); + TryAdd(pallet, ordered, options, templates); + } + + return templates; + } + + private static void TryAdd( + Pallet pallet, + IReadOnlyList ordered, + GenerationOptions options, + List templates) + { + var t = _strategy.Build(pallet, ordered, options); + if (t != null && IsDistinct(t, templates)) + templates.Add(t); + } + + private static bool IsDistinct(PalletTemplate candidate, IReadOnlyList existing) + { + var sig = Signature(candidate); + return existing.All(t => Signature(t) != sig); + } + + 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/Stack-Solver.csproj b/Stack-Solver.csproj index addd451..fb61efd 100644 --- a/Stack-Solver.csproj +++ b/Stack-Solver.csproj @@ -87,6 +87,11 @@ True Resources.resx + + True + True + Settings.settings + @@ -96,4 +101,11 @@ + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + diff --git a/ViewModels/Pages/LayerAnalyzerViewModel.cs b/ViewModels/Pages/LayerAnalyzerViewModel.cs index 0794728..cc7d090 100644 --- a/ViewModels/Pages/LayerAnalyzerViewModel.cs +++ b/ViewModels/Pages/LayerAnalyzerViewModel.cs @@ -4,7 +4,6 @@ using Stack_Solver.Models.Layering; using Stack_Solver.Models.Supports; using Stack_Solver.Services; -using Stack_Solver.Services.Layering; using System.Collections.ObjectModel; using System.Text; using System.Windows.Input; @@ -100,7 +99,7 @@ private void OnSettingsChanged(SettingsChangedMessage msg) _maxStackHeight = msg.MaxStackHeight; _maxStackWeight = msg.MaxStackWeight; _maxSkuOverhang = msg.MaxSkuOverhang; - _selectedSkus = [.. msg.Skus.Where(s => s.Quantity > 0)]; + _selectedSkus = [.. msg.Skus.Where(s => s.IsSelected && s.Quantity > 0)]; RecenterCameraTarget(); if (SelectedLayer != null) { @@ -204,45 +203,21 @@ private async Task Generate() { if (_selectedSkus.Count == 0) { - OutputText = "No SKUs with quantity greater than 0."; + OutputText = "No SKUs selected with quantity greater than 0."; return; } - var pallet = new Pallet("Pallet", _palletLength, _palletWidth, (int)Math.Round(_palletHeight)); - var options = new GenerationOptions(_solverTimeLimit, _maxCpsatCandidates, _blfAttempts); - var ct = localCts.Token; - - var strategiesList = new List + var pallet = new Pallet("Pallet", _palletLength, _palletWidth, (int)Math.Round(_palletHeight)) { - new BLFGenerationStrategy(), - new HomogeneousGenerationStrategy(), - new StripFillGenerationStrategy(), - new RadialPlacementGenerationStrategy() + MaxStackHeight = _maxStackHeight, + MaxStackWeight = _maxStackWeight, + MaxSkuOverhang = _maxSkuOverhang }; - - if (_useCpsat) - { - strategiesList.Add(new CPSATGenerationStrategy()); - } - var strategies = strategiesList.ToArray(); + var options = new GenerationOptions(_solverTimeLimit, _maxCpsatCandidates, _blfAttempts); + var ct = localCts.Token; _allLayers = await Task.Run(() => - { - var aggregate = new List(); - foreach (var strat in strategies) - { - if (ct.IsCancellationRequested) break; - try - { - var produced = strat.Generate(_selectedSkus, pallet, options); - if (produced != null && produced.Count > 0) - aggregate.AddRange(produced); - } - catch (OperationCanceledException) { throw; } - catch { } - } - return aggregate; - }, ct); + LayerGenerator.Generate(_selectedSkus, pallet, options, _useCpsat, ct), ct); if (ct.IsCancellationRequested) return; @@ -252,17 +227,6 @@ private async Task Generate() return; } - _allLayers = [.. _allLayers - .Where(l => - l?.Metadata != null && - !double.IsNaN(l.Metadata.Utilization) && - !double.IsInfinity(l.Metadata.Utilization) && - l.Metadata.Utilization > 0.0 && - l.Metadata.Utilization <= 1.0)]; - - foreach (var layer in _allLayers) - LayerGeometryOptimizer.CenterLayer(layer); - var topLayers = _allLayers .OrderByDescending(l => l.Metadata.Utilization) .ThenBy(l => l.Name) @@ -281,12 +245,7 @@ private async Task Generate() OutputText = "No layers after filtering."; } - LayerGenStats = $"Generated {_allLayers.Count} candidate layers using"; - foreach (var strat in strategies) - { - LayerGenStats += $" {strat.Name},"; - } - LayerGenStats = LayerGenStats.TrimEnd(',') + "."; + LayerGenStats = $"Generated {_allLayers.Count} candidate layers using BLF, Homogeneous, StripFill, Radial{(_useCpsat ? ", CPSAT" : "")}."; _events.Publish(new LayersGeneratedMessage(_allLayers)); } @@ -365,7 +324,12 @@ private void Update2DPreview() LayerRectangles.Clear(); return; } - var pallet = new Pallet("Pallet", _palletLength, _palletWidth, (int)Math.Round(_palletHeight)); + var pallet = new Pallet("Pallet", _palletLength, _palletWidth, (int)Math.Round(_palletHeight)) + { + MaxStackHeight = _maxStackHeight, + MaxStackWeight = _maxStackWeight, + MaxSkuOverhang = _maxSkuOverhang + }; _viz.Build2DRectangles(SelectedLayer, pallet, 1, LayerRectangles); } } diff --git a/ViewModels/Pages/PalletAnalyzerViewModel.cs b/ViewModels/Pages/PalletAnalyzerViewModel.cs index 67c58ed..676e2fc 100644 --- a/ViewModels/Pages/PalletAnalyzerViewModel.cs +++ b/ViewModels/Pages/PalletAnalyzerViewModel.cs @@ -1,32 +1,346 @@ +using Stack_Solver.Helpers.Rendering; using Stack_Solver.Infrastructure; +using Stack_Solver.Models; +using Stack_Solver.Models.Assignment; using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services; using System.Collections.ObjectModel; +using System.Text; +using System.Windows.Input; +using System.Windows.Media.Media3D; namespace Stack_Solver.ViewModels.Pages { public partial class PalletAnalyzerViewModel : ObservableObject { private readonly IEventAggregator _events; + private readonly PalletSceneBuilder _sceneBuilder = new(); + private CancellationTokenSource? _buildCts; + private CancellationTokenSource? _sceneCts; + private ViewportController? _viewportController; + + private List _availableLayers = []; + private int _palletLength, _palletWidth, _palletHeight; + private int _maxStackHeight = 180, _maxStackWeight = 950; + private double _maxSkuOverhang; + private List _selectedSkus = []; + private GenerationOptions _generationOptions = new(); + + [ObservableProperty] + private bool _isBuilding; + + [ObservableProperty] + private string _outputText = "Click 'Generate' to start."; + + [ObservableProperty] + private ObservableCollection _assignments = []; + + [ObservableProperty] + private TemplateAssignmentDisplay? _selectedAssignment; [ObservableProperty] - private string _outputText = string.Empty; + private bool _hasResults; [ObservableProperty] - private ObservableCollection _candidateLayers = []; + private bool _hasLayers; + + [ObservableProperty] + private ObservableCollection _selectedLayerTypes = []; + + [ObservableProperty] + private ObservableCollection _solutions = []; + + 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) { _events = events; _events.Subscribe(OnLayersGenerated); + _events.Subscribe(OnSettingsChanged); + ZoomCommand = new RelayCommand(delta => _viewportController?.Zoom(delta)); + BeginPanCommand = new RelayCommand(p => _viewportController?.BeginPan(p)); + PanCommand = new RelayCommand(p => _viewportController?.Pan(p)); + } + + public void AttachCamera(PerspectiveCamera camera) + { + if (camera == null) return; + if (_viewportController == null) + { + _viewportController = new ViewportController(camera, CurrentPalletCenter); + OnPropertyChanged(nameof(ViewportController)); + } + else + { + _viewportController.Target = CurrentPalletCenter; + } } + private Point3D CurrentPalletCenter => new(_palletLength / 2.0, 0, _palletWidth / 2.0); + private void OnLayersGenerated(LayersGeneratedMessage msg) { - CandidateLayers.Clear(); - foreach (var l in msg.Layers) - CandidateLayers.Add(l); + _availableLayers = msg.Layers; + HasLayers = _availableLayers.Count > 0; + HasResults = false; + Assignments.Clear(); + Solutions.Clear(); + OutputText = $"{_availableLayers.Count} candidate layers ready. Building pallets..."; + + _buildCts?.Cancel(); + _buildCts?.Dispose(); + _buildCts = new CancellationTokenSource(); + _ = BuildPalletsAsync(_buildCts.Token); + } + + private void OnSettingsChanged(SettingsChangedMessage msg) + { + _palletLength = msg.PalletLength; + _palletWidth = msg.PalletWidth; + _palletHeight = (int)Math.Round(msg.PalletHeight); + _maxStackHeight = msg.MaxStackHeight; + _maxStackWeight = msg.MaxStackWeight; + _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; + } + + private async Task BuildPalletsAsync(CancellationToken ct) + { + if (_availableLayers.Count == 0 || _selectedSkus.Count == 0) + { + OutputText = _availableLayers.Count == 0 + ? "No layers available." + : "No SKUs selected with quantity > 0."; + return; + } + + IsBuilding = true; + try + { + var pallet = new Pallet("Pallet", _palletLength, _palletWidth, _palletHeight) + { + MaxStackHeight = _maxStackHeight, + MaxStackWeight = _maxStackWeight, + MaxSkuOverhang = _maxSkuOverhang + }; + var demand = _selectedSkus.ToDictionary(s => s.SkuId, s => s.Quantity, StringComparer.Ordinal); + var options = _generationOptions; + var skus = _selectedSkus.ToList(); + var layersSnapshot = _availableLayers.ToList(); + + var result = await Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + var filtered = LayerMetricsCalculator.FilterLayers(layersSnapshot, options); + return GreedyAssignmentService.Assign(filtered, demand, pallet); + }, ct); + + ct.ThrowIfCancellationRequested(); + + if (result.HasLeftovers) + { + var tailInput = result; + result = 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); + } + + ct.ThrowIfCancellationRequested(); + + int index = 1; + foreach (var (template, count) in result.Assignments) + Assignments.Add(new TemplateAssignmentDisplay(template, count, skus, index++, _palletLength, _palletWidth, _palletHeight)); + + HasResults = Assignments.Count > 0; + SelectedAssignment = Assignments.FirstOrDefault(); + if (HasResults) + Solutions.Add(new SolutionDisplay(1, result)); + OutputText = BuildSummaryText(result, skus); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OutputText = $"Error: {ex.Message}"; + } + finally + { + IsBuilding = false; + } + } - OutputText = $"{CandidateLayers.Count} candidate layers received."; + partial void OnSelectedAssignmentChanged(TemplateAssignmentDisplay? value) + { + SelectedLayerTypes = value != null + ? new ObservableCollection(value.LayerTypes) + : []; + + if (value != null && _viewportController != null) + { + double totalHeight = _palletHeight + value.Template.TotalHeight; + var center = new Point3D(_palletLength / 2.0, totalHeight / 2.0, _palletWidth / 2.0); + double distance = Math.Sqrt(_palletLength * _palletLength + _palletWidth * _palletWidth + totalHeight * totalHeight) * 1.5; + _viewportController.ResetView(center, distance); + } + + _sceneCts?.Cancel(); + _sceneCts?.Dispose(); + _sceneCts = new CancellationTokenSource(); + var ct = _sceneCts.Token; + + if (value != null) + _ = UpdateSceneAsync(value.Template, ct); + else + Scene.Children.Clear(); + } + + private async Task UpdateSceneAsync(PalletTemplate template, CancellationToken ct) + { + try + { + await _sceneBuilder.BuildAsync(Scene, template, _palletLength, _palletWidth, _palletHeight, ct); + } + catch (OperationCanceledException) { } + } + + private static AssignmentResult MergeResults(AssignmentResult main, AssignmentResult extra) + { + var merged = main.Assignments.ToList(); + foreach (var (template, count) in extra.Assignments) + { + var idx = merged.FindIndex(a => a.Template.Id == template.Id); + if (idx >= 0) + merged[idx] = (template, merged[idx].Count + count); + else + merged.Add((template, count)); + } + return new AssignmentResult { Assignments = merged, Leftovers = extra.Leftovers }; + } + + private static List BuildLeftoverSkus(List originalSkus, IReadOnlyDictionary leftovers) + { + return [.. originalSkus + .Select(s => (Sku: s, Rem: leftovers.GetValueOrDefault(s.SkuId))) + .Where(x => x.Rem > 0) + .Select(x => new SKU + { + SkuId = x.Sku.SkuId, + Name = x.Sku.Name, + Length = x.Sku.Length, + Width = x.Sku.Width, + Height = x.Sku.Height, + Weight = x.Sku.Weight, + Rotatable = x.Sku.Rotatable, + Notes = x.Sku.Notes, + Quantity = x.Rem + })]; + } + + private static string BuildSummaryText(AssignmentResult result, List skus) + { + var skuMap = skus.ToDictionary(s => s.SkuId, s => s.Name, StringComparer.Ordinal); + 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."); + //} + return sb.ToString(); + } + } + + public class TemplateAssignmentDisplay + { + public PalletTemplate Template { get; } + public int Count { get; } + public string Name { get; } + public string SkuSummary { get; } + public string Contents { get; } + public string LoadDimensions { get; } + public string Weight { get; } + public string Efficiency { get; } + public IReadOnlyList LayerTypes { get; } + + public TemplateAssignmentDisplay(PalletTemplate template, int count, IEnumerable skuLookup, int index, int palletLength, int palletWidth, int palletHeight) + { + Template = template; + Count = count; + Name = $"Type {index}"; + Efficiency = template.AverageLayerUtilization.ToString("P0"); + Weight = template.TotalWeight.ToString("N0"); + LoadDimensions = $"{palletLength}x{palletWidth}x{(int)Math.Round(palletHeight + template.TotalHeight)}"; + + var skuMap = skuLookup.ToDictionary(s => s.SkuId, s => s.Name, StringComparer.Ordinal); + SkuSummary = string.Join(" | ", template.SkuCounts + .OrderBy(kvp => skuMap.GetValueOrDefault(kvp.Key, kvp.Key), StringComparer.Ordinal) + .Select(kvp => $"{skuMap.GetValueOrDefault(kvp.Key, kvp.Key)} ×{kvp.Value}")); + Contents = string.Join(", ", template.SkuCounts + .OrderBy(kvp => skuMap.GetValueOrDefault(kvp.Key, kvp.Key), StringComparer.Ordinal) + .Select(kvp => $"{kvp.Value}x {skuMap.GetValueOrDefault(kvp.Key, kvp.Key)}")); + LayerTypes = [.. template.Layers + .GroupBy(l => l.Id) + .Select(g => new LayerTypeDisplay(g.First(), g.Count(), skuMap))]; + } + } + + public class LayerTypeDisplay(Layer layer, int count, IReadOnlyDictionary skuNames) + { + public string Name { get; } = layer.Name; + public int Count { get; } = count; + public string Contents { get; } = string.Join(", ", layer.Items + .GroupBy(i => i.SkuType.SkuId) + .Select(g => $"{g.Count()}x {skuNames.GetValueOrDefault(g.Key, g.Key)}")); + public string Utilization { get; } = layer.Metadata.Utilization.ToString("P0"); + } + + public class SolutionDisplay + { + public int Number { get; } + public string Name { 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) + { + Number = number; + Name = "Greedy"; + TotalPallets = result.TotalPallets; + PalletTypes = result.Assignments.Count; + TotalItemsPacked = result.Assignments.Sum(a => a.Template.TotalBoxCount * a.Count); + var weightedUtil = result.TotalPallets > 0 + ? result.Assignments.Sum(a => a.Template.AverageLayerUtilization * a.Count) / result.TotalPallets + : 0; + Efficiency = weightedUtil.ToString("P0"); } } } diff --git a/ViewModels/Pages/PalletBuilderSettingsViewModel.cs b/ViewModels/Pages/PalletBuilderSettingsViewModel.cs index 1bca60b..0ef157c 100644 --- a/ViewModels/Pages/PalletBuilderSettingsViewModel.cs +++ b/ViewModels/Pages/PalletBuilderSettingsViewModel.cs @@ -16,6 +16,7 @@ public partial class PalletBuilderSettingsViewModel : ObservableObject private readonly IEventAggregator _events; private readonly IValidator _settingsValidator; private readonly IValidator _skuQuantityValidator; + private readonly IUserSettingsService _userSettings; private readonly GenerationOptions _defaults; private readonly PalletDefaultsOptions _palletDefaults; private bool _isInitialized; @@ -98,12 +99,14 @@ public PalletBuilderSettingsViewModel( IOptions genOptions, IOptions palletDefaults, IValidator settingsValidator, - IValidator skuQuantityValidator) + IValidator skuQuantityValidator, + IUserSettingsService userSettings) { _skuRepository = skuRepository; _events = events; _settingsValidator = settingsValidator; _skuQuantityValidator = skuQuantityValidator; + _userSettings = userSettings; _defaults = GenerationOptions.From(genOptions.Value); _palletDefaults = palletDefaults.Value ?? new PalletDefaultsOptions(); _skuRepository.SkuAdded += OnSkuAdded; @@ -169,6 +172,8 @@ private void SelectPallet(Pallet? pallet) PublishSettingsChanged(); } + public void NotifySelectionChanged() => PublishSettingsChanged(); + public async Task UpdateSkuAsync(SKU sku, CancellationToken ct = default) { if (sku == null) return; @@ -191,6 +196,7 @@ public async Task UpdateSkuAsync(SKU sku, CancellationToken ct = default) partial void OnPalletHeightChanged(double value) => PublishSettingsChanged(); partial void OnUseCpsatChanged(bool value) => PublishSettingsChanged(); partial void OnMaxCpsatCandidatesChanged(int value) => PublishSettingsChanged(); + partial void OnBlfAttemptsChanged(int value) => PublishSettingsChanged(); partial void OnSolverTimeLimitChanged(int value) => PublishSettingsChanged(); partial void OnMaxStackHeightChanged(int value) => PublishSettingsChanged(); partial void OnMaxStackWeightChanged(int value) => PublishSettingsChanged(); @@ -221,6 +227,30 @@ private void PublishSettingsChanged() UseCpsat, MaxCpsatCandidates, BlfAttempts, SolverTimeLimit, MaxStackHeight, MaxStackWeight, MaxSkuOverhang, [.. Skus])); + + if (_isInitialized) + { + var palletOpts = new PalletDefaultsOptions + { + DefaultCatalog = _palletDefaults.DefaultCatalog, + DefaultPalletName = _palletDefaults.DefaultPalletName, + PalletLength = PalletLength, + PalletWidth = PalletWidth, + PalletHeight = PalletHeight, + MaxStackHeight = MaxStackHeight, + MaxStackWeight = MaxStackWeight, + MaxSkuOverhang = MaxSkuOverhang + }; + var genOpts = new GenerationOptions + { + MaxSolverTime = SolverTimeLimit, + MaxCPSATCandidates = MaxCpsatCandidates, + BLFAttempts = BlfAttempts, + MaxLayerStability = _defaults.MaxLayerStability, + PerSkuTopLayerFraction = _defaults.PerSkuTopLayerFraction + }; + _ = _userSettings.SaveAsync(palletOpts, genOpts); + } } private void OnSkuAdded(object? sender, SKU sku) diff --git a/ViewModels/Pages/PalletBuilderViewModel.cs b/ViewModels/Pages/PalletBuilderViewModel.cs index a1385fa..a2a9010 100644 --- a/ViewModels/Pages/PalletBuilderViewModel.cs +++ b/ViewModels/Pages/PalletBuilderViewModel.cs @@ -15,9 +15,10 @@ public partial class PalletBuilderViewModel( IOptions genOptions, IOptions palletDefaults, IValidator settingsValidator, - IValidator skuQuantityValidator) : ObservableObject + IValidator skuQuantityValidator, + IUserSettingsService userSettings) : ObservableObject { - public PalletBuilderSettingsViewModel Settings { get; } = new PalletBuilderSettingsViewModel(skuRepository, events, genOptions, palletDefaults, settingsValidator, skuQuantityValidator); + 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); diff --git a/Views/Pages/PalletBuilderPage.xaml b/Views/Pages/PalletBuilderPage.xaml index 9edacc7..d7aee5c 100644 --- a/Views/Pages/PalletBuilderPage.xaml +++ b/Views/Pages/PalletBuilderPage.xaml @@ -45,9 +45,9 @@ - + - + @@ -126,7 +126,7 @@ - + + + + + + + + @@ -164,7 +174,7 @@ + Minimum="0" SmallChange="10" LargeChange="10"/> @@ -251,7 +261,7 @@ - + @@ -259,8 +269,9 @@ - - + + + @@ -276,13 +287,18 @@ IsReadOnly="True" HeadersVisibility="Column" ScrollViewer.VerticalScrollBarVisibility="Auto" - BorderThickness="0"> + BorderThickness="0" + ItemsSource="{Binding ViewModel.PalletAnalyzer.Assignments}" + SelectedItem="{Binding ViewModel.PalletAnalyzer.SelectedAssignment, Mode=TwoWay}"> - - - - - + + + + + + + + @@ -293,12 +309,13 @@ IsReadOnly="True" HeadersVisibility="Column" ScrollViewer.VerticalScrollBarVisibility="Auto" - BorderThickness="0"> + BorderThickness="0" + ItemsSource="{Binding ViewModel.PalletAnalyzer.SelectedLayerTypes}"> - - - - + + + + @@ -306,17 +323,41 @@ - + - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/Views/Pages/PalletBuilderPage.xaml.cs b/Views/Pages/PalletBuilderPage.xaml.cs index 8cfaa4e..2c5ef97 100644 --- a/Views/Pages/PalletBuilderPage.xaml.cs +++ b/Views/Pages/PalletBuilderPage.xaml.cs @@ -27,9 +27,9 @@ private async void OnLoaded(object? sender, RoutedEventArgs e) { await ViewModel.OnNavigatedToAsync(); if (ViewModel.LayerAnalyzer.ViewportController == null && MainPerspectiveCamera is PerspectiveCamera cam) - { ViewModel.LayerAnalyzer.AttachCamera(cam); - } + if (ViewModel.PalletAnalyzer.ViewportController == null && PalletPerspectiveCamera is PerspectiveCamera palletCam) + ViewModel.PalletAnalyzer.AttachCamera(palletCam); } private void MainViewPort_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) @@ -76,6 +76,11 @@ private async void SkuSelectionGrid_CellEditEnding(object sender, DataGridCellEd } } + private void SkuCheckBox_Click(object sender, RoutedEventArgs e) + { + ViewModel.Settings.NotifySelectionChanged(); + } + private void TopHelpButton_Click(object sender, RoutedEventArgs e) { helpFlyout.IsOpen = !helpFlyout.IsOpen;