Files
CutList/CutList.Core/Nesting/ExhaustiveFitEngine.cs
AJ Isaacs b19ecf3610 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>
2026-02-01 15:16:40 -05:00

199 lines
7.2 KiB
C#

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; }
}
}
}