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:
60
CutList.Core/Nesting/Pipeline/DuplicateBinsStep.cs
Normal file
60
CutList.Core/Nesting/Pipeline/DuplicateBinsStep.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates duplicate bins when the same packing pattern can be repeated.
|
||||
/// If there are enough remaining items to fill another bin identically,
|
||||
/// creates copies to reduce iteration overhead.
|
||||
/// </summary>
|
||||
public class DuplicateBinsStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
// Process bins that were created before this step
|
||||
// We need a copy since we'll be adding new bins
|
||||
var originalBins = context.Bins.ToList();
|
||||
|
||||
foreach (var originalBin in originalBins)
|
||||
{
|
||||
CreateDuplicateBins(originalBin, context);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateDuplicateBins(Bin originalBin, PackingContext context)
|
||||
{
|
||||
int duplicateCount = GetDuplicateCount(originalBin, context.RemainingItems);
|
||||
|
||||
for (int i = 0; i < duplicateCount; i++)
|
||||
{
|
||||
if (!context.CanAddMoreBins())
|
||||
break;
|
||||
|
||||
var newBin = context.CreateBin();
|
||||
|
||||
foreach (var item in originalBin.Items)
|
||||
{
|
||||
var matchingItem = context.RemainingItems.FirstOrDefault(a => a.Length == item.Length);
|
||||
if (matchingItem != null)
|
||||
{
|
||||
newBin.AddItem(matchingItem);
|
||||
context.RemainingItems.Remove(matchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
context.Bins.Add(newBin);
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetDuplicateCount(Bin bin, List<BinItem> remainingItems)
|
||||
{
|
||||
int count = int.MaxValue;
|
||||
|
||||
foreach (var lengthGroup in bin.Items.GroupBy(i => i.Length))
|
||||
{
|
||||
int availableCount = remainingItems.Count(i => i.Length == lengthGroup.Key);
|
||||
count = Math.Min(count, availableCount / lengthGroup.Count());
|
||||
}
|
||||
|
||||
return count == int.MaxValue ? 0 : count;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
CutList.Core/Nesting/Pipeline/FilterOversizedItemsStep.cs
Normal file
22
CutList.Core/Nesting/Pipeline/FilterOversizedItemsStep.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes items that are too large to fit in the stock length.
|
||||
/// These items are moved to the OversizedItems collection.
|
||||
/// </summary>
|
||||
public class FilterOversizedItemsStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
var oversized = context.RemainingItems
|
||||
.Where(item => item.Length > context.StockLength)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in oversized)
|
||||
{
|
||||
context.RemainingItems.Remove(item);
|
||||
context.OversizedItems.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
CutList.Core/Nesting/Pipeline/FirstFitDecreasingStep.cs
Normal file
34
CutList.Core/Nesting/Pipeline/FirstFitDecreasingStep.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements the First-Fit Decreasing (FFD) bin packing algorithm.
|
||||
/// Assumes items are already sorted by length descending.
|
||||
/// Places each item in the first bin that has enough space.
|
||||
/// </summary>
|
||||
public class FirstFitDecreasingStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
while (context.RemainingItems.Count > 0 && context.CanAddMoreBins())
|
||||
{
|
||||
var bin = context.CreateBin();
|
||||
FillBin(bin, context.RemainingItems);
|
||||
context.Bins.Add(bin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillBin(Bin bin, List<BinItem> remainingItems)
|
||||
{
|
||||
for (int i = 0; i < remainingItems.Count; i++)
|
||||
{
|
||||
var item = remainingItems[i];
|
||||
if (bin.RemainingLength >= item.Length)
|
||||
{
|
||||
bin.AddItem(item);
|
||||
remainingItems.RemoveAt(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
CutList.Core/Nesting/Pipeline/IPackingStep.cs
Normal file
15
CutList.Core/Nesting/Pipeline/IPackingStep.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single step in the packing pipeline.
|
||||
/// Each step modifies the PackingContext to progress toward a final result.
|
||||
/// </summary>
|
||||
public interface IPackingStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes this step, modifying the context as needed.
|
||||
/// </summary>
|
||||
/// <param name="context">The mutable packing context.</param>
|
||||
void Execute(PackingContext context);
|
||||
}
|
||||
}
|
||||
123
CutList.Core/Nesting/Pipeline/OptimizationStep.cs
Normal file
123
CutList.Core/Nesting/Pipeline/OptimizationStep.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to improve bin utilization by swapping items.
|
||||
/// For each bin, tries replacing a packed item with unpacked items
|
||||
/// to achieve better space utilization.
|
||||
/// </summary>
|
||||
public class OptimizationStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
foreach (var bin in context.Bins)
|
||||
{
|
||||
while (TryImprovePacking(bin, context.RemainingItems, context.Spacing))
|
||||
{
|
||||
// Keep optimizing until no improvement can be made
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryImprovePacking(Bin bin, List<BinItem> remainingItems, double spacing)
|
||||
{
|
||||
if (bin.Items.Count == 0)
|
||||
return false;
|
||||
|
||||
if (remainingItems.Count < 2)
|
||||
return false;
|
||||
|
||||
var lengthGroups = GroupItemsByLength(bin.Items);
|
||||
var shortestLengthItemAvailable = remainingItems.Min(i => i.Length);
|
||||
|
||||
foreach (var group in lengthGroups)
|
||||
{
|
||||
var minRemainingLength = bin.RemainingLength;
|
||||
var firstItem = group.Items.FirstOrDefault();
|
||||
if (firstItem == null)
|
||||
continue;
|
||||
|
||||
bin.RemoveItem(firstItem);
|
||||
|
||||
for (int i = 0; i < remainingItems.Count; i++)
|
||||
{
|
||||
var item1 = remainingItems[i];
|
||||
|
||||
if (item1.Length > bin.RemainingLength)
|
||||
continue;
|
||||
|
||||
var testBin = new Bin(bin.RemainingLength)
|
||||
{
|
||||
Spacing = spacing
|
||||
};
|
||||
testBin.AddItem(item1);
|
||||
|
||||
for (int j = i + 1; j < remainingItems.Count; j++)
|
||||
{
|
||||
if (testBin.RemainingLength < shortestLengthItemAvailable)
|
||||
break;
|
||||
|
||||
var item2 = remainingItems[j];
|
||||
|
||||
if (item2.Length > testBin.RemainingLength)
|
||||
continue;
|
||||
|
||||
testBin.AddItem(item2);
|
||||
}
|
||||
|
||||
if (testBin.RemainingLength < minRemainingLength)
|
||||
{
|
||||
// Found improvement: swap the items
|
||||
remainingItems.Add(firstItem);
|
||||
bin.AddItems(testBin.Items);
|
||||
|
||||
foreach (var item in testBin.Items)
|
||||
{
|
||||
remainingItems.Remove(item);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bin.AddItem(firstItem);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<LengthGroup> GroupItemsByLength(IReadOnlyList<BinItem> items)
|
||||
{
|
||||
var groups = new List<LengthGroup>();
|
||||
var groupMap = new Dictionary<double, LengthGroup>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!groupMap.TryGetValue(item.Length, out var group))
|
||||
{
|
||||
group = new LengthGroup
|
||||
{
|
||||
Length = item.Length,
|
||||
Items = new List<BinItem>()
|
||||
};
|
||||
groupMap[item.Length] = group;
|
||||
groups.Add(group);
|
||||
}
|
||||
group.Items.Add(item);
|
||||
}
|
||||
|
||||
groups.Sort((a, b) => b.Length.CompareTo(a.Length));
|
||||
if (groups.Count > 0)
|
||||
{
|
||||
groups.RemoveAt(0); // Remove the largest length group
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private class LengthGroup
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public List<BinItem> Items { get; set; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
89
CutList.Core/Nesting/Pipeline/PackingContext.cs
Normal file
89
CutList.Core/Nesting/Pipeline/PackingContext.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Mutable context passed through the packing pipeline.
|
||||
/// Contains the working state that pipeline steps modify.
|
||||
/// </summary>
|
||||
public class PackingContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new packing context from a request.
|
||||
/// </summary>
|
||||
public PackingContext(PackingRequest request)
|
||||
{
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
RemainingItems = new List<BinItem>(request.Items);
|
||||
Bins = new List<Bin>();
|
||||
OversizedItems = new List<BinItem>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The original immutable request.
|
||||
/// </summary>
|
||||
public PackingRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Items that have not yet been placed in a bin.
|
||||
/// Pipeline steps remove items from this list as they are packed.
|
||||
/// </summary>
|
||||
public List<BinItem> RemainingItems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Items that are too large to fit in any stock bin.
|
||||
/// </summary>
|
||||
public List<BinItem> OversizedItems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The bins that have been created and filled.
|
||||
/// </summary>
|
||||
public List<Bin> Bins { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property for stock length from the request.
|
||||
/// </summary>
|
||||
public double StockLength => Request.StockLength;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property for spacing from the request.
|
||||
/// </summary>
|
||||
public double Spacing => Request.Spacing;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property for max bin count from the request.
|
||||
/// </summary>
|
||||
public int MaxBinCount => Request.MaxBinCount;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if more bins can be added without exceeding the limit.
|
||||
/// </summary>
|
||||
public bool CanAddMoreBins()
|
||||
{
|
||||
if (MaxBinCount == -1)
|
||||
return true;
|
||||
|
||||
return Bins.Count < MaxBinCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new bin with the stock length and spacing from the request.
|
||||
/// </summary>
|
||||
public Bin CreateBin()
|
||||
{
|
||||
return new Bin(StockLength)
|
||||
{
|
||||
Spacing = Spacing
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final PackResult from the current context state.
|
||||
/// </summary>
|
||||
public PackResult ToResult()
|
||||
{
|
||||
var allUnusedItems = new List<BinItem>();
|
||||
allUnusedItems.AddRange(OversizedItems);
|
||||
allUnusedItems.AddRange(RemainingItems);
|
||||
return new PackResult(Bins, allUnusedItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
CutList.Core/Nesting/Pipeline/PackingPipeline.cs
Normal file
39
CutList.Core/Nesting/Pipeline/PackingPipeline.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a sequence of packing steps to produce a final result.
|
||||
/// Provides a composable, testable approach to bin packing algorithms.
|
||||
/// </summary>
|
||||
public class PackingPipeline
|
||||
{
|
||||
private readonly List<IPackingStep> _steps = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a step to the pipeline.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to add.</param>
|
||||
/// <returns>This pipeline for fluent chaining.</returns>
|
||||
public PackingPipeline AddStep(IPackingStep step)
|
||||
{
|
||||
_steps.Add(step ?? throw new ArgumentNullException(nameof(step)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes all steps in sequence and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="request">The packing request to process.</param>
|
||||
/// <returns>The packing result.</returns>
|
||||
public PackResult Execute(PackingRequest request)
|
||||
{
|
||||
var context = new PackingContext(request);
|
||||
|
||||
foreach (var step in _steps)
|
||||
{
|
||||
step.Execute(context);
|
||||
}
|
||||
|
||||
return context.ToResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
20
CutList.Core/Nesting/Pipeline/SortBinItemsStep.cs
Normal file
20
CutList.Core/Nesting/Pipeline/SortBinItemsStep.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts items within each bin by length descending for consistent output.
|
||||
/// </summary>
|
||||
public class SortBinItemsStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
foreach (var bin in context.Bins)
|
||||
{
|
||||
bin.SortItems((a, b) =>
|
||||
{
|
||||
int comparison = b.Length.CompareTo(a.Length);
|
||||
return comparison != 0 ? comparison : a.Name.CompareTo(b.Name);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
CutList.Core/Nesting/Pipeline/SortBinsByUtilizationStep.cs
Normal file
20
CutList.Core/Nesting/Pipeline/SortBinsByUtilizationStep.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts bins by utilization (highest first) for optimal presentation.
|
||||
/// Secondary sort by item count (fewer items first for ties).
|
||||
/// </summary>
|
||||
public class SortBinsByUtilizationStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
var sorted = context.Bins
|
||||
.OrderByDescending(b => b.Utilization)
|
||||
.ThenBy(b => b.Items.Count)
|
||||
.ToList();
|
||||
|
||||
context.Bins.Clear();
|
||||
context.Bins.AddRange(sorted);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
CutList.Core/Nesting/Pipeline/SortItemsDescendingStep.cs
Normal file
19
CutList.Core/Nesting/Pipeline/SortItemsDescendingStep.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace CutList.Core.Nesting.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts remaining items by length in descending order.
|
||||
/// This is the "Decreasing" part of First-Fit Decreasing (FFD) algorithm.
|
||||
/// </summary>
|
||||
public class SortItemsDescendingStep : IPackingStep
|
||||
{
|
||||
public void Execute(PackingContext context)
|
||||
{
|
||||
var sorted = context.RemainingItems
|
||||
.OrderByDescending(item => item.Length)
|
||||
.ToList();
|
||||
|
||||
context.RemainingItems.Clear();
|
||||
context.RemainingItems.AddRange(sorted);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user