Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Light" />
<ui:ControlsDictionary />
<ResourceDictionary Source="Styles/SnackbarStyle.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
Expand Down
5 changes: 4 additions & 1 deletion App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public partial class App
// Service containing navigation, same as INavigationWindow... but without window
services.AddSingleton<INavigationService, NavigationService>();

services.AddSingleton<ISnackbarService, SnackbarService>();

// Main window with navigation
services.AddSingleton<INavigationWindow, MainWindow>();
services.AddSingleton<MainWindowViewModel>();
Expand All @@ -81,7 +83,8 @@ public partial class App
sp.GetRequiredService<IOptions<PalletDefaultsOptions>>(),
sp.GetRequiredService<IValidator<PalletSettingsDto>>(),
sp.GetRequiredService<IValidator<SkuQuantityDto>>(),
sp.GetRequiredService<IUserSettingsService>()));
sp.GetRequiredService<IUserSettingsService>(),
sp.GetRequiredService<ISnackbarService>()));
services.AddSingleton<TruckLoadingPage>();
services.AddSingleton<TruckLoadingViewModel>();
services.AddSingleton<JobManagerPage>();
Expand Down
15 changes: 15 additions & 0 deletions Converters/BoolToInfoBarSeverityConverter.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
30 changes: 26 additions & 4 deletions Helpers/Rendering/PalletSceneBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ public class PalletSceneBuilder
private readonly Dictionary<string, Brush> _skuBrushCache = [];
private readonly Lock _cacheLock = new();

private Dictionary<GeometryModel3D, string> _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,
Expand All @@ -21,6 +29,8 @@ public async Task BuildAsync(
{
ct.ThrowIfCancellationRequested();
var g = new Model3DGroup();
var mapping = new Dictionary<GeometryModel3D, string>();
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)));
Expand All @@ -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)
{
Expand All @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions Services/CPSATAssignmentService.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class CPSATAssignmentService
{
public static AssignmentResult Assign(
IReadOnlyList<PalletTemplate> templates,
IReadOnlyDictionary<string, int> 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<string, IntVar>(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<LinearExpr> { 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<PalletTemplate> templates,
IntVar[] x,
IReadOnlyDictionary<string, IntVar> 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<string, int>(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<PalletTemplate> templates,
IntVar[] x,
BoolVar[] y,
IReadOnlyDictionary<string, IntVar> 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<string, int>(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}"));
}
}
2 changes: 1 addition & 1 deletion Services/Layering/HomogeneousGenerationStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public List<Layer> Generate(List<SKU> 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;
Expand Down
Loading
Loading