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