refactor: Redesign nesting engines with pipeline pattern and add exhaustive search
- Rename Result to PackResult to avoid confusion with Result<T> - Add PackingRequest as immutable configuration replacing mutable engine state - Add PackingStrategy enum (AdvancedFit, BestFit, Exhaustive) - Implement pipeline pattern for composable packing steps - Rewrite AdvancedFitEngine as stateless using pipeline - Rewrite BestFitEngine as stateless - Add ExhaustiveFitEngine with symmetry breaking for optimal solutions - Tries all bin assignments to find minimum bins - Falls back to AdvancedFit for >20 items - Configurable threshold via constructor - Update IEngine/IEngineFactory interfaces for new pattern - Add strategy parameter to MCP tools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
198
CutList.Core/Nesting/ExhaustiveFitEngine.cs
Normal file
198
CutList.Core/Nesting/ExhaustiveFitEngine.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
namespace CutList.Core.Nesting
|
||||
{
|
||||
/// <summary>
|
||||
/// Exhaustive bin packing engine that tries all possible combinations
|
||||
/// to find the optimal solution. Falls back to AdvancedFitEngine for
|
||||
/// item counts exceeding the threshold due to exponential complexity.
|
||||
/// </summary>
|
||||
public class ExhaustiveFitEngine : IEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Default maximum number of items before falling back to AdvancedFitEngine.
|
||||
/// Testing showed 20 items is safe (~100ms worst case), while 21+ can take seconds.
|
||||
/// </summary>
|
||||
public const int DefaultMaxItems = 20;
|
||||
|
||||
private readonly IEngine _fallbackEngine;
|
||||
private readonly int _maxItems;
|
||||
|
||||
public ExhaustiveFitEngine() : this(DefaultMaxItems)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exhaustive engine with a custom item threshold for testing.
|
||||
/// </summary>
|
||||
/// <param name="maxItems">Maximum items before falling back. Use int.MaxValue to disable fallback.</param>
|
||||
public ExhaustiveFitEngine(int maxItems)
|
||||
{
|
||||
_maxItems = maxItems;
|
||||
_fallbackEngine = new AdvancedFitEngine();
|
||||
}
|
||||
|
||||
public PackResult Pack(PackingRequest request)
|
||||
{
|
||||
// Filter oversized items first
|
||||
var validItems = new List<BinItem>();
|
||||
var oversizedItems = new List<BinItem>();
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
if (item.Length > request.StockLength)
|
||||
oversizedItems.Add(item);
|
||||
else
|
||||
validItems.Add(item);
|
||||
}
|
||||
|
||||
// Fall back to AdvancedFit for large item counts
|
||||
if (validItems.Count > _maxItems)
|
||||
{
|
||||
var fallbackResult = _fallbackEngine.Pack(request);
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
// Sort items descending for better pruning
|
||||
var sortedItems = validItems.OrderByDescending(i => i.Length).ToList();
|
||||
|
||||
// Find optimal solution using exhaustive search
|
||||
var bestSolution = new SearchState
|
||||
{
|
||||
Bins = new List<List<BinItem>>(),
|
||||
BinCount = int.MaxValue
|
||||
};
|
||||
|
||||
var currentState = new SearchState
|
||||
{
|
||||
Bins = new List<List<BinItem>>(),
|
||||
BinCount = 0
|
||||
};
|
||||
|
||||
Search(sortedItems, 0, currentState, bestSolution, request);
|
||||
|
||||
// Build result from best solution
|
||||
var result = new PackResult();
|
||||
result.AddItemsNotUsed(oversizedItems);
|
||||
|
||||
foreach (var binItems in bestSolution.Bins)
|
||||
{
|
||||
var bin = new Bin(request.StockLength) { Spacing = request.Spacing };
|
||||
foreach (var item in binItems.OrderByDescending(i => i.Length))
|
||||
{
|
||||
bin.AddItem(item);
|
||||
}
|
||||
result.AddBin(bin);
|
||||
}
|
||||
|
||||
// Sort bins by utilization
|
||||
var sortedBins = result.Bins
|
||||
.OrderByDescending(b => b.Utilization)
|
||||
.ThenBy(b => b.Items.Count)
|
||||
.ToList();
|
||||
|
||||
var finalResult = new PackResult();
|
||||
finalResult.AddItemsNotUsed(oversizedItems);
|
||||
finalResult.AddBins(sortedBins);
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
private void Search(
|
||||
List<BinItem> items,
|
||||
int itemIndex,
|
||||
SearchState current,
|
||||
SearchState best,
|
||||
PackingRequest request)
|
||||
{
|
||||
// All items placed - check if this is better
|
||||
if (itemIndex >= items.Count)
|
||||
{
|
||||
if (current.BinCount < best.BinCount ||
|
||||
(current.BinCount == best.BinCount && GetTotalWaste(current, request) < GetTotalWaste(best, request)))
|
||||
{
|
||||
best.BinCount = current.BinCount;
|
||||
best.Bins = current.Bins.Select(b => b.ToList()).ToList();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pruning: if we already have more bins than best, stop
|
||||
if (current.BinCount >= best.BinCount)
|
||||
return;
|
||||
|
||||
// Respect max bin count
|
||||
if (current.BinCount >= request.MaxBinCount)
|
||||
return;
|
||||
|
||||
var item = items[itemIndex];
|
||||
|
||||
// Symmetry breaking: if this item has the same length as the previous item,
|
||||
// only place it in bins with index >= where previous item went.
|
||||
// This avoids redundant exploration of equivalent permutations.
|
||||
int minBinIndex = 0;
|
||||
if (itemIndex > 0 && items[itemIndex - 1].Length == item.Length)
|
||||
{
|
||||
minBinIndex = current.LastBinIndexUsed;
|
||||
}
|
||||
|
||||
// Try placing in each existing bin (respecting symmetry constraint)
|
||||
for (int i = minBinIndex; i < current.Bins.Count; i++)
|
||||
{
|
||||
var binUsed = GetBinUsedLength(current.Bins[i], request.Spacing);
|
||||
var remaining = request.StockLength - binUsed;
|
||||
|
||||
// Item fits if adding it (with spacing) stays within tolerance
|
||||
// Bin class allows going over by up to spacing amount
|
||||
if (item.Length <= remaining)
|
||||
{
|
||||
// Place item in this bin
|
||||
current.Bins[i].Add(item);
|
||||
var prevBinIndex = current.LastBinIndexUsed;
|
||||
current.LastBinIndexUsed = i;
|
||||
Search(items, itemIndex + 1, current, best, request);
|
||||
current.LastBinIndexUsed = prevBinIndex;
|
||||
current.Bins[i].RemoveAt(current.Bins[i].Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Try placing in a new bin (if allowed)
|
||||
if (current.BinCount < request.MaxBinCount && current.BinCount < best.BinCount)
|
||||
{
|
||||
int newBinIndex = current.Bins.Count;
|
||||
current.Bins.Add(new List<BinItem> { item });
|
||||
current.BinCount++;
|
||||
var prevBinIndex = current.LastBinIndexUsed;
|
||||
current.LastBinIndexUsed = newBinIndex;
|
||||
Search(items, itemIndex + 1, current, best, request);
|
||||
current.LastBinIndexUsed = prevBinIndex;
|
||||
current.Bins.RemoveAt(current.Bins.Count - 1);
|
||||
current.BinCount--;
|
||||
}
|
||||
}
|
||||
|
||||
private double GetBinUsedLength(List<BinItem> binItems, double spacing)
|
||||
{
|
||||
if (binItems.Count == 0)
|
||||
return 0;
|
||||
|
||||
return binItems.Sum(i => i.Length) + binItems.Count * spacing;
|
||||
}
|
||||
|
||||
private double GetTotalWaste(SearchState state, PackingRequest request)
|
||||
{
|
||||
double totalWaste = 0;
|
||||
foreach (var bin in state.Bins)
|
||||
{
|
||||
var used = GetBinUsedLength(bin, request.Spacing);
|
||||
totalWaste += request.StockLength - used;
|
||||
}
|
||||
return totalWaste;
|
||||
}
|
||||
|
||||
private class SearchState
|
||||
{
|
||||
public List<List<BinItem>> Bins { get; set; } = new();
|
||||
public int BinCount { get; set; }
|
||||
public int LastBinIndexUsed { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user