diff --git a/CutList.Core/Nesting/AdvancedFitEngine.cs b/CutList.Core/Nesting/AdvancedFitEngine.cs
index 5f7a024..f8e24f6 100644
--- a/CutList.Core/Nesting/AdvancedFitEngine.cs
+++ b/CutList.Core/Nesting/AdvancedFitEngine.cs
@@ -1,248 +1,33 @@
-using System.Data;
+using CutList.Core.Nesting.Pipeline;
namespace CutList.Core.Nesting
{
+ ///
+ /// Advanced bin packing engine using First-Fit Decreasing with optimization.
+ /// This is a stateless engine that uses a composable pipeline of steps.
+ ///
public class AdvancedFitEngine : IEngine
{
+ private readonly PackingPipeline _pipeline;
+
public AdvancedFitEngine()
{
- Bins = new List();
+ _pipeline = new PackingPipeline()
+ .AddStep(new FilterOversizedItemsStep())
+ .AddStep(new SortItemsDescendingStep())
+ .AddStep(new FirstFitDecreasingStep())
+ .AddStep(new OptimizationStep())
+ .AddStep(new SortBinItemsStep())
+ .AddStep(new DuplicateBinsStep())
+ .AddStep(new SortBinsByUtilizationStep());
}
- public double StockLength { get; set; }
-
- public double Spacing { get; set; }
-
- public int MaxBinCount { get; set; } = int.MaxValue;
-
- private List Items { get; set; }
-
- private List Bins { get; set; }
-
- public Result Pack(List items)
+ ///
+ /// Packs items into bins using the FFD algorithm with optimization passes.
+ ///
+ public PackResult Pack(PackingRequest request)
{
- if (StockLength <= 0)
- throw new Exception("Stock length must be greater than 0");
-
- Items = items.OrderByDescending(i => i.Length).ToList();
-
- var result = new Result();
- var itemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
- result.AddItemsNotUsed(itemsTooLarge);
-
- Items.RemoveAll(item => itemsTooLarge.Contains(item));
-
- CreateBins();
-
- var finalItemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
- result.AddItemsNotUsed(finalItemsTooLarge);
- result.AddBins(Bins);
-
- foreach (var bin in result.Bins)
- {
- foreach (var item in bin.Items)
- {
- Items.Remove(item);
- }
- }
-
- result.AddItemsNotUsed(Items);
-
- return result;
+ return _pipeline.Execute(request);
}
-
- private void CreateBins()
- {
- while (Items.Count > 0 && CanAddMoreBins())
- {
- var bin = new Bin(StockLength)
- {
- Spacing = Spacing
- };
-
- FillBin(bin);
-
- while (TryImprovePacking(bin))
- {
- }
-
- bin.SortItems((a, b) =>
- {
- int comparison = b.Length.CompareTo(a.Length);
- return comparison != 0 ? comparison : a.Length.CompareTo(b.Length);
- });
-
- Bins.Add(bin);
-
- CreateDuplicateBins(bin);
- }
-
- Bins = Bins
- .OrderByDescending(b => b.Utilization)
- .ThenBy(b => b.Items.Count)
- .ToList();
- }
-
- private bool CanAddMoreBins()
- {
- if (MaxBinCount == -1)
- return true;
-
- if (Bins.Count < MaxBinCount)
- return true;
-
- return false;
- }
-
- private void FillBin(Bin bin)
- {
- for (int i = 0; i < Items.Count; i++)
- {
- if (bin.RemainingLength >= Items[i].Length)
- {
- bin.AddItem(Items[i]);
- Items.RemoveAt(i);
- i--;
- }
- }
- }
-
- private void CreateDuplicateBins(Bin originalBin)
- {
- // Count how many times the bin can be duplicated
- int duplicateCount = GetDuplicateCount(originalBin);
-
- for (int i = 0; i < duplicateCount; i++)
- {
- if (!CanAddMoreBins())
- break;
-
- var newBin = new Bin(originalBin.Length)
- {
- Spacing = Spacing
- };
-
- foreach (var item in originalBin.Items)
- {
- var newItem = Items.FirstOrDefault(a => a.Length == item.Length);
- newBin.AddItem(newItem);
- Items.Remove(newItem);
- }
-
- Bins.Add(newBin);
- }
- }
-
- private int GetDuplicateCount(Bin bin)
- {
- int count = int.MaxValue;
-
- foreach (var item in bin.Items.GroupBy(i => i.Length))
- {
- int availableCount = Items.Count(i => i.Length == item.Key);
- count = Math.Min(count, availableCount / item.Count());
- }
-
- return count;
- }
-
- private bool TryImprovePacking(Bin bin)
- {
- if (bin.Items.Count == 0)
- return false;
-
- if (Items.Count < 2)
- return false;
-
- var lengthGroups = GroupItemsByLength(bin.Items);
-
- var shortestLengthItemAvailable = Items.Min(i => i.Length);
-
- foreach (var group in lengthGroups)
- {
- var minRemainingLength = bin.RemainingLength;
- var firstItem = group.Items.FirstOrDefault();
- bin.RemoveItem(firstItem);
-
- for (int i = 0; i < Items.Count; i++)
- {
- var item1 = Items[i];
-
- if (Items[i].Length > bin.RemainingLength)
- continue;
-
- var bin2 = new Bin(bin.RemainingLength);
- bin2.Spacing = bin.Spacing;
- bin2.AddItem(item1);
-
- for (int j = i + 1; j < Items.Count; j++)
- {
- if (bin2.RemainingLength < shortestLengthItemAvailable)
- break;
-
- var item2 = Items[j];
-
- if (item2.Length > bin2.RemainingLength)
- continue;
-
- bin2.AddItem(item2);
- }
-
- if (bin2.RemainingLength < minRemainingLength)
- {
- Items.Add(firstItem);
- bin.AddItems(bin2.Items);
-
- foreach (var item in bin2.Items)
- {
- Items.Remove(item);
- }
-
- // improvement made
- return true;
- }
- }
-
- bin.AddItem(firstItem);
- }
-
- return false;
- }
-
- private List GroupItemsByLength(IEnumerable items)
- {
- var groups = new List();
- var groupMap = new Dictionary();
-
- foreach (var item in items)
- {
- if (!groupMap.TryGetValue(item.Length, out var group))
- {
- group = new LengthGroup
- {
- Length = item.Length,
- Items = new List()
- };
- 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;
- }
- }
-
- internal class LengthGroup
- {
- public double Length { get; set; }
- public List Items { get; set; }
}
}
-
diff --git a/CutList.Core/Nesting/BestFitEngine.cs b/CutList.Core/Nesting/BestFitEngine.cs
index 3352130..c9b514b 100644
--- a/CutList.Core/Nesting/BestFitEngine.cs
+++ b/CutList.Core/Nesting/BestFitEngine.cs
@@ -1,92 +1,70 @@
-using System.Data;
-
namespace CutList.Core.Nesting
{
+ ///
+ /// Best-Fit Decreasing bin packing engine.
+ /// Places each item in the bin with the least remaining space that can still fit it.
+ /// This is a stateless engine - all state is passed via PackingRequest.
+ ///
public class BestFitEngine : IEngine
{
- public double StockLength { get; set; }
-
- public double Spacing { get; set; }
-
- public int MaxBinCount { get; set; } = int.MaxValue;
-
- private List Items { get; set; }
-
- public Result Pack(List items)
+ ///
+ /// Packs items into bins using the Best-Fit Decreasing algorithm.
+ ///
+ public PackResult Pack(PackingRequest request)
{
- if (StockLength <= 0)
- throw new Exception("Stock length must be greater than 0");
+ var result = new PackResult();
+ var items = request.Items.OrderByDescending(i => i.Length).ToList();
+ var bins = new List();
- Items = items.OrderByDescending(i => i.Length).ToList();
-
- var result = new Result();
- var itemsTooLarge = Items.Where(i => i.Length > StockLength).ToList();
- result.AddItemsNotUsed(itemsTooLarge);
-
- foreach (var item in itemsTooLarge)
+ // Filter oversized items
+ var oversizedItems = items.Where(i => i.Length > request.StockLength).ToList();
+ foreach (var item in oversizedItems)
{
- Items.Remove(item);
+ items.Remove(item);
+ result.AddItemNotUsed(item);
}
- var bins = GetBins();
- result.AddBins(bins);
-
- foreach (var bin in bins)
+ // Pack remaining items using best-fit
+ foreach (var item in items)
{
- foreach (var item in bin.Items)
+ if (!TryFindBestBin(bins, item.Length, out var bestBin))
{
- Items.Remove(item);
+ if (bins.Count < request.MaxBinCount)
+ {
+ bestBin = CreateBin(request);
+ bins.Add(bestBin);
+ }
+ }
+
+ if (bestBin != null)
+ {
+ bestBin.AddItem(item);
+ }
+ else
+ {
+ result.AddItemNotUsed(item);
}
}
- result.AddItemsNotUsed(Items);
+ // Sort bins by utilization
+ var sortedBins = bins
+ .OrderByDescending(b => b.Utilization)
+ .ThenBy(b => b.Items.Count);
+
+ result.AddBins(sortedBins);
return result;
}
- private List GetBins()
+ private static Bin CreateBin(PackingRequest request)
{
- var bins = new List();
-
- foreach (var item in Items)
+ return new Bin(request.StockLength)
{
- Bin best_bin;
-
- if (!FindBin(bins.ToArray(), item.Length, out best_bin))
- {
- if (item.Length > StockLength)
- continue;
-
- if (bins.Count < MaxBinCount)
- {
- best_bin = CreateBin();
- bins.Add(best_bin);
- }
- }
-
- if (best_bin != null)
- best_bin.AddItem(item);
-
-
- }
-
- return bins
- .OrderByDescending(b => b.Utilization)
- .ThenBy(b => b.Items.Count)
- .ToList();
- }
-
- private Bin CreateBin()
- {
- var length = StockLength;
-
- return new Bin(length)
- {
- Spacing = Spacing
+ Spacing = request.Spacing
};
}
- private static bool FindBin(IEnumerable bins, double length, out Bin found)
+ private static bool TryFindBestBin(IEnumerable bins, double length, out Bin? found)
{
found = null;
@@ -95,14 +73,13 @@ namespace CutList.Core.Nesting
if (bin.RemainingLength < length)
continue;
- if (found == null)
- found = bin;
-
- if (bin.RemainingLength < found.RemainingLength)
+ if (found == null || bin.RemainingLength < found.RemainingLength)
+ {
found = bin;
+ }
}
- return (found != null);
+ return found != null;
}
}
-}
\ No newline at end of file
+}
diff --git a/CutList.Core/Nesting/EngineFactory.cs b/CutList.Core/Nesting/EngineFactory.cs
index 97e12e8..f45dbbe 100644
--- a/CutList.Core/Nesting/EngineFactory.cs
+++ b/CutList.Core/Nesting/EngineFactory.cs
@@ -1,18 +1,19 @@
namespace CutList.Core.Nesting
{
///
- /// Default implementation of IEngineFactory that creates AdvancedFitEngine instances.
- /// Can be extended to support different engine types based on configuration.
+ /// Default implementation of IEngineFactory that creates packing engines
+ /// based on the specified strategy.
///
public class EngineFactory : IEngineFactory
{
- public IEngine CreateEngine(double stockLength, double spacing, int maxBinCount)
+ public IEngine CreateEngine(PackingStrategy strategy = PackingStrategy.AdvancedFit)
{
- return new AdvancedFitEngine
+ return strategy switch
{
- StockLength = stockLength,
- Spacing = spacing,
- MaxBinCount = maxBinCount
+ PackingStrategy.AdvancedFit => new AdvancedFitEngine(),
+ PackingStrategy.BestFit => new BestFitEngine(),
+ PackingStrategy.Exhaustive => new ExhaustiveFitEngine(),
+ _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown packing strategy")
};
}
}
diff --git a/CutList.Core/Nesting/ExhaustiveFitEngine.cs b/CutList.Core/Nesting/ExhaustiveFitEngine.cs
new file mode 100644
index 0000000..7858390
--- /dev/null
+++ b/CutList.Core/Nesting/ExhaustiveFitEngine.cs
@@ -0,0 +1,198 @@
+namespace CutList.Core.Nesting
+{
+ ///
+ /// 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.
+ ///
+ public class ExhaustiveFitEngine : IEngine
+ {
+ ///
+ /// Default maximum number of items before falling back to AdvancedFitEngine.
+ /// Testing showed 20 items is safe (~100ms worst case), while 21+ can take seconds.
+ ///
+ public const int DefaultMaxItems = 20;
+
+ private readonly IEngine _fallbackEngine;
+ private readonly int _maxItems;
+
+ public ExhaustiveFitEngine() : this(DefaultMaxItems)
+ {
+ }
+
+ ///
+ /// Creates an exhaustive engine with a custom item threshold for testing.
+ ///
+ /// Maximum items before falling back. Use int.MaxValue to disable fallback.
+ public ExhaustiveFitEngine(int maxItems)
+ {
+ _maxItems = maxItems;
+ _fallbackEngine = new AdvancedFitEngine();
+ }
+
+ public PackResult Pack(PackingRequest request)
+ {
+ // Filter oversized items first
+ var validItems = new List();
+ var oversizedItems = new List();
+
+ 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>(),
+ BinCount = int.MaxValue
+ };
+
+ var currentState = new SearchState
+ {
+ Bins = new List>(),
+ 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 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 { 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 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> Bins { get; set; } = new();
+ public int BinCount { get; set; }
+ public int LastBinIndexUsed { get; set; }
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/IEngine.cs b/CutList.Core/Nesting/IEngine.cs
index e7d57d3..a3f8622 100644
--- a/CutList.Core/Nesting/IEngine.cs
+++ b/CutList.Core/Nesting/IEngine.cs
@@ -1,7 +1,16 @@
-namespace CutList.Core.Nesting
+namespace CutList.Core.Nesting
{
+ ///
+ /// Interface for bin packing engines.
+ /// Engines are stateless - all configuration is passed via PackingRequest.
+ ///
public interface IEngine
{
- Result Pack(List items);
+ ///
+ /// Packs items into bins according to the request configuration.
+ ///
+ /// The packing configuration and items.
+ /// The packing result with bins and unused items.
+ PackResult Pack(PackingRequest request);
}
-}
\ No newline at end of file
+}
diff --git a/CutList.Core/Nesting/IEngineFactory.cs b/CutList.Core/Nesting/IEngineFactory.cs
index beacf50..e39b072 100644
--- a/CutList.Core/Nesting/IEngineFactory.cs
+++ b/CutList.Core/Nesting/IEngineFactory.cs
@@ -7,12 +7,10 @@ namespace CutList.Core.Nesting
public interface IEngineFactory
{
///
- /// Creates a configured engine instance for bin packing.
+ /// Creates an engine instance for the specified packing strategy.
///
- /// The length of stock bins
- /// The spacing/kerf between items
- /// Maximum number of bins to create
- /// A configured IEngine instance
- IEngine CreateEngine(double stockLength, double spacing, int maxBinCount);
+ /// The packing strategy to use.
+ /// A configured IEngine instance.
+ IEngine CreateEngine(PackingStrategy strategy = PackingStrategy.AdvancedFit);
}
}
diff --git a/CutList.Core/Nesting/MultiBinEngine.cs b/CutList.Core/Nesting/MultiBinEngine.cs
index 45a956f..0f06439 100644
--- a/CutList.Core/Nesting/MultiBinEngine.cs
+++ b/CutList.Core/Nesting/MultiBinEngine.cs
@@ -1,8 +1,13 @@
-namespace CutList.Core.Nesting
+namespace CutList.Core.Nesting
{
- public class MultiBinEngine : IEngine
+ ///
+ /// Engine that coordinates packing across multiple bin types with different sizes.
+ /// Uses priority ordering to determine which bin types to fill first.
+ ///
+ public class MultiBinEngine
{
private readonly IEngineFactory _engineFactory;
+ private readonly List _bins;
public MultiBinEngine() : this(new EngineFactory())
{
@@ -14,16 +19,14 @@
_bins = new List();
}
- private readonly List _bins;
-
///
- /// Gets the read-only collection of bins.
+ /// Gets the read-only collection of bin types.
/// Use SetBins() to configure bins for packing.
///
public IReadOnlyList Bins => _bins.AsReadOnly();
///
- /// Sets the bins to use for packing.
+ /// Sets the bin types to use for packing.
///
public void SetBins(IEnumerable bins)
{
@@ -34,26 +37,48 @@
}
}
+ ///
+ /// The spacing/kerf between items.
+ ///
public double Spacing { get; set; }
- public Result Pack(List items)
+ ///
+ /// The packing strategy to use for each bin type.
+ ///
+ public PackingStrategy Strategy { get; set; } = PackingStrategy.AdvancedFit;
+
+ ///
+ /// Packs items across all configured bin types.
+ ///
+ public PackResult Pack(List items)
{
- var bins = _bins
+ var sortedBinTypes = _bins
.Where(b => b.Length > 0)
.OrderBy(b => b.Priority)
.ThenBy(b => b.Length)
.ToList();
- var result = new Result();
+ var result = new PackResult();
var remainingItems = new List(items);
- foreach (var bin in bins)
- {
- var engine = _engineFactory.CreateEngine(bin.Length, Spacing, bin.Quantity);
- var r = engine.Pack(remainingItems);
+ var engine = _engineFactory.CreateEngine(Strategy);
- result.AddBins(r.Bins);
- remainingItems = r.ItemsNotUsed.ToList();
+ foreach (var binType in sortedBinTypes)
+ {
+ if (remainingItems.Count == 0)
+ break;
+
+ var request = new PackingRequest(
+ items: remainingItems,
+ stockLength: binType.Length,
+ spacing: Spacing,
+ maxBinCount: binType.Quantity
+ );
+
+ var packResult = engine.Pack(request);
+
+ result.AddBins(packResult.Bins);
+ remainingItems = packResult.ItemsNotUsed.ToList();
}
result.AddItemsNotUsed(remainingItems);
@@ -61,4 +86,4 @@
return result;
}
}
-}
\ No newline at end of file
+}
diff --git a/CutList.Core/Nesting/Result.cs b/CutList.Core/Nesting/PackResult.cs
similarity index 51%
rename from CutList.Core/Nesting/Result.cs
rename to CutList.Core/Nesting/PackResult.cs
index ed358f0..0d87de6 100644
--- a/CutList.Core/Nesting/Result.cs
+++ b/CutList.Core/Nesting/PackResult.cs
@@ -1,18 +1,34 @@
-namespace CutList.Core.Nesting
+namespace CutList.Core.Nesting
{
- public class Result
+ ///
+ /// Represents the result of a bin packing operation.
+ /// Contains the packed bins and any items that could not be placed.
+ ///
+ public class PackResult
{
private readonly List _itemsNotUsed;
private readonly List _bins;
- public Result()
+ public PackResult()
{
_itemsNotUsed = new List();
_bins = new List();
}
+ public PackResult(IEnumerable bins, IEnumerable itemsNotUsed)
+ {
+ _bins = bins?.ToList() ?? new List();
+ _itemsNotUsed = itemsNotUsed?.ToList() ?? new List();
+ }
+
+ ///
+ /// Items that could not be placed in any bin (e.g., too large for stock).
+ ///
public IReadOnlyList ItemsNotUsed => _itemsNotUsed;
+ ///
+ /// The bins containing packed items.
+ ///
public IReadOnlyList Bins => _bins;
public void AddItemNotUsed(BinItem item)
@@ -35,4 +51,4 @@
_bins.AddRange(bins);
}
}
-}
\ No newline at end of file
+}
diff --git a/CutList.Core/Nesting/PackingRequest.cs b/CutList.Core/Nesting/PackingRequest.cs
new file mode 100644
index 0000000..0d3098f
--- /dev/null
+++ b/CutList.Core/Nesting/PackingRequest.cs
@@ -0,0 +1,51 @@
+namespace CutList.Core.Nesting
+{
+ ///
+ /// Immutable configuration object for a bin packing operation.
+ /// Replaces mutable engine state with a clean request/response pattern.
+ ///
+ public sealed class PackingRequest
+ {
+ ///
+ /// Creates a new packing request.
+ ///
+ /// The items to be packed.
+ /// The length of stock bins.
+ /// The spacing/kerf between items (default 0).
+ /// Maximum number of bins to create (default unlimited).
+ public PackingRequest(
+ IReadOnlyList items,
+ double stockLength,
+ double spacing = 0,
+ int maxBinCount = int.MaxValue)
+ {
+ if (stockLength <= 0)
+ throw new ArgumentException("Stock length must be greater than 0", nameof(stockLength));
+
+ Items = items ?? throw new ArgumentNullException(nameof(items));
+ StockLength = stockLength;
+ Spacing = spacing;
+ MaxBinCount = maxBinCount;
+ }
+
+ ///
+ /// The items to be packed into bins.
+ ///
+ public IReadOnlyList Items { get; }
+
+ ///
+ /// The length of each stock bin.
+ ///
+ public double StockLength { get; }
+
+ ///
+ /// The spacing/kerf between items (blade width).
+ ///
+ public double Spacing { get; }
+
+ ///
+ /// Maximum number of bins to create. Use int.MaxValue for unlimited.
+ ///
+ public int MaxBinCount { get; }
+ }
+}
diff --git a/CutList.Core/Nesting/PackingStrategy.cs b/CutList.Core/Nesting/PackingStrategy.cs
new file mode 100644
index 0000000..2e68984
--- /dev/null
+++ b/CutList.Core/Nesting/PackingStrategy.cs
@@ -0,0 +1,31 @@
+namespace CutList.Core.Nesting
+{
+ ///
+ /// Specifies the bin packing strategy/algorithm to use.
+ ///
+ public enum PackingStrategy
+ {
+ ///
+ /// First-Fit Decreasing with optimization passes.
+ /// Sorts items by length descending, then packs using first-fit.
+ /// Includes optimization to improve packing by swapping items.
+ /// Best for general-purpose use cases.
+ ///
+ AdvancedFit,
+
+ ///
+ /// Best-Fit Decreasing algorithm.
+ /// Sorts items by length descending, then places each item in the bin
+ /// with the least remaining space that can still fit it.
+ /// Simpler but can be effective for certain distributions.
+ ///
+ BestFit,
+
+ ///
+ /// Exhaustive search that tries all possible combinations.
+ /// Guarantees optimal solution but has exponential time complexity.
+ /// Automatically falls back to AdvancedFit for more than 15 items.
+ ///
+ Exhaustive
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/DuplicateBinsStep.cs b/CutList.Core/Nesting/Pipeline/DuplicateBinsStep.cs
new file mode 100644
index 0000000..d10c12c
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/DuplicateBinsStep.cs
@@ -0,0 +1,60 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// 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.
+ ///
+ 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 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;
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/FilterOversizedItemsStep.cs b/CutList.Core/Nesting/Pipeline/FilterOversizedItemsStep.cs
new file mode 100644
index 0000000..f68c6fa
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/FilterOversizedItemsStep.cs
@@ -0,0 +1,22 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Removes items that are too large to fit in the stock length.
+ /// These items are moved to the OversizedItems collection.
+ ///
+ 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);
+ }
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/FirstFitDecreasingStep.cs b/CutList.Core/Nesting/Pipeline/FirstFitDecreasingStep.cs
new file mode 100644
index 0000000..cef5536
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/FirstFitDecreasingStep.cs
@@ -0,0 +1,34 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// 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.
+ ///
+ 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 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--;
+ }
+ }
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/IPackingStep.cs b/CutList.Core/Nesting/Pipeline/IPackingStep.cs
new file mode 100644
index 0000000..b9de2c0
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/IPackingStep.cs
@@ -0,0 +1,15 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Represents a single step in the packing pipeline.
+ /// Each step modifies the PackingContext to progress toward a final result.
+ ///
+ public interface IPackingStep
+ {
+ ///
+ /// Executes this step, modifying the context as needed.
+ ///
+ /// The mutable packing context.
+ void Execute(PackingContext context);
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/OptimizationStep.cs b/CutList.Core/Nesting/Pipeline/OptimizationStep.cs
new file mode 100644
index 0000000..711c0b3
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/OptimizationStep.cs
@@ -0,0 +1,123 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Attempts to improve bin utilization by swapping items.
+ /// For each bin, tries replacing a packed item with unpacked items
+ /// to achieve better space utilization.
+ ///
+ 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 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 GroupItemsByLength(IReadOnlyList items)
+ {
+ var groups = new List();
+ var groupMap = new Dictionary();
+
+ foreach (var item in items)
+ {
+ if (!groupMap.TryGetValue(item.Length, out var group))
+ {
+ group = new LengthGroup
+ {
+ Length = item.Length,
+ Items = new List()
+ };
+ 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 Items { get; set; } = new();
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/PackingContext.cs b/CutList.Core/Nesting/Pipeline/PackingContext.cs
new file mode 100644
index 0000000..e582b3a
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/PackingContext.cs
@@ -0,0 +1,89 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Mutable context passed through the packing pipeline.
+ /// Contains the working state that pipeline steps modify.
+ ///
+ public class PackingContext
+ {
+ ///
+ /// Creates a new packing context from a request.
+ ///
+ public PackingContext(PackingRequest request)
+ {
+ Request = request ?? throw new ArgumentNullException(nameof(request));
+ RemainingItems = new List(request.Items);
+ Bins = new List();
+ OversizedItems = new List();
+ }
+
+ ///
+ /// The original immutable request.
+ ///
+ public PackingRequest Request { get; }
+
+ ///
+ /// Items that have not yet been placed in a bin.
+ /// Pipeline steps remove items from this list as they are packed.
+ ///
+ public List RemainingItems { get; }
+
+ ///
+ /// Items that are too large to fit in any stock bin.
+ ///
+ public List OversizedItems { get; }
+
+ ///
+ /// The bins that have been created and filled.
+ ///
+ public List Bins { get; }
+
+ ///
+ /// Convenience property for stock length from the request.
+ ///
+ public double StockLength => Request.StockLength;
+
+ ///
+ /// Convenience property for spacing from the request.
+ ///
+ public double Spacing => Request.Spacing;
+
+ ///
+ /// Convenience property for max bin count from the request.
+ ///
+ public int MaxBinCount => Request.MaxBinCount;
+
+ ///
+ /// Checks if more bins can be added without exceeding the limit.
+ ///
+ public bool CanAddMoreBins()
+ {
+ if (MaxBinCount == -1)
+ return true;
+
+ return Bins.Count < MaxBinCount;
+ }
+
+ ///
+ /// Creates a new bin with the stock length and spacing from the request.
+ ///
+ public Bin CreateBin()
+ {
+ return new Bin(StockLength)
+ {
+ Spacing = Spacing
+ };
+ }
+
+ ///
+ /// Builds the final PackResult from the current context state.
+ ///
+ public PackResult ToResult()
+ {
+ var allUnusedItems = new List();
+ allUnusedItems.AddRange(OversizedItems);
+ allUnusedItems.AddRange(RemainingItems);
+ return new PackResult(Bins, allUnusedItems);
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/PackingPipeline.cs b/CutList.Core/Nesting/Pipeline/PackingPipeline.cs
new file mode 100644
index 0000000..ca6c2ee
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/PackingPipeline.cs
@@ -0,0 +1,39 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Executes a sequence of packing steps to produce a final result.
+ /// Provides a composable, testable approach to bin packing algorithms.
+ ///
+ public class PackingPipeline
+ {
+ private readonly List _steps = new();
+
+ ///
+ /// Adds a step to the pipeline.
+ ///
+ /// The step to add.
+ /// This pipeline for fluent chaining.
+ public PackingPipeline AddStep(IPackingStep step)
+ {
+ _steps.Add(step ?? throw new ArgumentNullException(nameof(step)));
+ return this;
+ }
+
+ ///
+ /// Executes all steps in sequence and returns the result.
+ ///
+ /// The packing request to process.
+ /// The packing result.
+ public PackResult Execute(PackingRequest request)
+ {
+ var context = new PackingContext(request);
+
+ foreach (var step in _steps)
+ {
+ step.Execute(context);
+ }
+
+ return context.ToResult();
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/SortBinItemsStep.cs b/CutList.Core/Nesting/Pipeline/SortBinItemsStep.cs
new file mode 100644
index 0000000..50655d7
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/SortBinItemsStep.cs
@@ -0,0 +1,20 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Sorts items within each bin by length descending for consistent output.
+ ///
+ 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);
+ });
+ }
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/SortBinsByUtilizationStep.cs b/CutList.Core/Nesting/Pipeline/SortBinsByUtilizationStep.cs
new file mode 100644
index 0000000..159a054
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/SortBinsByUtilizationStep.cs
@@ -0,0 +1,20 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Sorts bins by utilization (highest first) for optimal presentation.
+ /// Secondary sort by item count (fewer items first for ties).
+ ///
+ 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);
+ }
+ }
+}
diff --git a/CutList.Core/Nesting/Pipeline/SortItemsDescendingStep.cs b/CutList.Core/Nesting/Pipeline/SortItemsDescendingStep.cs
new file mode 100644
index 0000000..79405aa
--- /dev/null
+++ b/CutList.Core/Nesting/Pipeline/SortItemsDescendingStep.cs
@@ -0,0 +1,19 @@
+namespace CutList.Core.Nesting.Pipeline
+{
+ ///
+ /// Sorts remaining items by length in descending order.
+ /// This is the "Decreasing" part of First-Fit Decreasing (FFD) algorithm.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/CutList.Mcp/CutListTools.cs b/CutList.Mcp/CutListTools.cs
index 05f2574..7caad07 100644
--- a/CutList.Mcp/CutListTools.cs
+++ b/CutList.Mcp/CutListTools.cs
@@ -27,7 +27,9 @@ public static class CutListTools
[Description("Stock bins available. Each needs: length (string), quantity (int, use -1 for unlimited), priority (int, lower = used first, default 25)")]
StockBinInput[] stockBins,
[Description("Blade kerf/width in inches (default 0.125)")]
- double kerf = 0.125)
+ double kerf = 0.125,
+ [Description("Packing strategy: 'advanced' (default), 'bestfit', or 'exhaustive' (optimal but slow, max 15 items)")]
+ string strategy = "advanced")
{
try
{
@@ -39,7 +41,7 @@ public static class CutListTools
if (binsError != null)
return new CutListResult { Success = false, Error = binsError };
- var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf);
+ var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf, ParseStrategy(strategy));
// Convert results
var resultBins = new List();
@@ -176,7 +178,9 @@ public static class CutListTools
[Description("Blade kerf/width in inches (default 0.125)")]
double kerf = 0.125,
[Description("File path to save the report. If not provided, saves to a temp file.")]
- string? filePath = null)
+ string? filePath = null,
+ [Description("Packing strategy: 'advanced' (default), 'bestfit', or 'exhaustive' (optimal but slow, max 15 items)")]
+ string strategy = "advanced")
{
try
{
@@ -188,7 +192,7 @@ public static class CutListTools
if (binsError != null)
return new CutListReportResult { Success = false, Error = binsError };
- var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf);
+ var packResult = RunPackingAlgorithm(binItems!, multiBins!, kerf, ParseStrategy(strategy));
// Determine file path
var outputPath = string.IsNullOrWhiteSpace(filePath)
@@ -247,14 +251,25 @@ public static class CutListTools
return (multiBins, null);
}
- private static Core.Nesting.Result RunPackingAlgorithm(List items, List bins, double kerf)
+ private static PackResult RunPackingAlgorithm(List items, List bins, double kerf, PackingStrategy packingStrategy = PackingStrategy.AdvancedFit)
{
var engine = new MultiBinEngine();
engine.SetBins(bins);
engine.Spacing = kerf;
+ engine.Strategy = packingStrategy;
return engine.Pack(items);
}
+ private static PackingStrategy ParseStrategy(string strategy)
+ {
+ return strategy?.ToLowerInvariant() switch
+ {
+ "bestfit" or "best" => PackingStrategy.BestFit,
+ "exhaustive" or "optimal" => PackingStrategy.Exhaustive,
+ _ => PackingStrategy.AdvancedFit
+ };
+ }
+
private static double ParseLength(string input)
{
if (string.IsNullOrWhiteSpace(input))
diff --git a/CutList/Services/CutListService.cs b/CutList/Services/CutListService.cs
index 51ca624..34e810a 100644
--- a/CutList/Services/CutListService.cs
+++ b/CutList/Services/CutListService.cs
@@ -18,7 +18,7 @@ namespace CutList.Services
/// The available stock bins
/// The cutting tool to use (determines kerf/spacing)
/// Result containing the packing result with optimized bins and unused items, or error message
- public Result Pack(List parts, List stockBins, Tool cuttingTool)
+ public Result Pack(List parts, List stockBins, Tool cuttingTool)
{
try
{
@@ -30,11 +30,11 @@ namespace CutList.Services
engine.Spacing = cuttingTool.Kerf;
var packResult = engine.Pack(binItems);
- return Result.Success(packResult);
+ return Result.Success(packResult);
}
catch (Exception ex)
{
- return Result.Failure($"Packing failed: {ex.Message}");
+ return Result.Failure($"Packing failed: {ex.Message}");
}
}