diff --git a/OpenNest.Engine/MultiPlateNester.cs b/OpenNest.Engine/MultiPlateNester.cs index b5819b1..028205e 100644 --- a/OpenNest.Engine/MultiPlateNester.cs +++ b/OpenNest.Engine/MultiPlateNester.cs @@ -15,8 +15,33 @@ namespace OpenNest Small, } - public static class MultiPlateNester + public class MultiPlateNester { + private readonly Plate _template; + private readonly List _plateOptions; + private readonly double _salvageRate; + private readonly double _minRemnantSize; + private readonly List _platePool; + private readonly IProgress _progress; + private readonly CancellationToken _token; + + private MultiPlateNester( + Plate template, List plateOptions, + double salvageRate, double minRemnantSize, + List existingPlates, + IProgress progress, CancellationToken token) + { + _template = template; + _plateOptions = plateOptions; + _salvageRate = salvageRate; + _minRemnantSize = minRemnantSize; + _platePool = InitializePlatePool(existingPlates); + _progress = progress; + _token = token; + } + + // --- Static Utility Methods --- + public static List SortItems(List items, PartSortOrder sortOrder) { switch (sortOrder) @@ -66,34 +91,10 @@ namespace OpenNest return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize; } - public static List FindScrapZones(Plate plate, double minRemnantSize) + public static List FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly) { - var finder = RemnantFinder.FromPlate(plate); - var remnants = finder.FindRemnants(); - - var scrap = new List(); - foreach (var remnant in remnants) - { - if (IsScrapRemnant(remnant, minRemnantSize)) - scrap.Add(remnant); - } - - return scrap; - } - - public static List FindViableRemnants(Plate plate, double minRemnantSize) - { - var finder = RemnantFinder.FromPlate(plate); - var remnants = finder.FindRemnants(); - - var viable = new List(); - foreach (var remnant in remnants) - { - if (!IsScrapRemnant(remnant, minRemnantSize)) - viable.Add(remnant); - } - - return viable; + var remnants = RemnantFinder.FromPlate(plate).FindRemnants(); + return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList(); } public struct UpgradeDecision @@ -121,7 +122,6 @@ namespace OpenNest if (options == null || options.Count == 0 || minBounds == null) return plate; - // Find smallest option that fits the part (by cost ascending). var sorted = options.OrderBy(o => o.Cost).ToList(); foreach (var option in sorted) @@ -141,7 +141,6 @@ namespace OpenNest } } - // No option fits — use template size. return plate; } @@ -167,7 +166,7 @@ namespace OpenNest }; } - // --- Main Orchestration --- + // --- Main Entry Point --- public static MultiPlateResult Nest( List items, @@ -181,378 +180,51 @@ namespace OpenNest IProgress progress, CancellationToken token) { - var result = new MultiPlateResult(); - - if (items == null || items.Count == 0) - return result; - - // Initialize plate pool from existing plates. - var platePool = new List(); - - if (existingPlates != null) - { - foreach (var plate in existingPlates) - platePool.Add(new PlateResult { Plate = plate, IsNew = false }); - } - - // Sort items by selected order. - var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder); - - // Single pass — process each drawing batch. - foreach (var item in sorted) - { - if (token.IsCancellationRequested) - break; - - if (item.Quantity <= 0) - continue; - - var bb = item.Drawing.Program.BoundingBox(); - var placed = false; - - // Try to place on existing plates in the pool. - placed = TryPlaceOnExistingPlates(item, bb, platePool, template, - minRemnantSize, progress, token); - - // Classify against template to decide if this item warrants its own plate. - // Small parts are deferred to the consolidation pass where they get packed - // together on shared plates instead of each getting their own. - var templateClass = Classify(bb, template.WorkArea()); - - if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small) - { - placed = PlaceOnNewPlates(item, bb, platePool, template, - plateOptions, minRemnantSize, progress, token) || placed; - } - - // If items remain, try upgrade-vs-new-plate. - if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small - && plateOptions != null && plateOptions.Count > 0) - { - placed = TryUpgradeOrNewPlate(item, bb, platePool, template, - plateOptions, salvageRate, minRemnantSize, progress, token) || placed; - } - - // Don't add to unplaced yet — consolidation pass will handle leftovers. - } - - // Consolidation pass: pack remaining items together on shared plates - // using the engine's multi-item Nest() method instead of one-drawing-per-plate. - var leftovers = sorted.Where(i => i.Quantity > 0).ToList(); - - if (leftovers.Count > 0 && allowPlateCreation && !token.IsCancellationRequested) - { - // First try to pack leftovers into remaining space on existing plates. - foreach (var pr in platePool) - { - if (token.IsCancellationRequested) - break; - - // Repeatedly find the largest remnant and pack into it. - // Recalculate after each fill to avoid overlapping stale remnants. - var anyPlacedOnThis = true; - while (anyPlacedOnThis && !token.IsCancellationRequested) - { - anyPlacedOnThis = false; - - var remaining = leftovers.Where(i => i.Quantity > 0).ToList(); - if (remaining.Count == 0) - break; - - var finder = RemnantFinder.FromPlate(pr.Plate); - var remnants = finder.FindRemnants(); - if (remnants.Count == 0) - break; - - // Try the largest remnant. - var remnant = remnants[0]; - - var engine = NestEngineRegistry.Create(pr.Plate); - var cloned = remaining.Select(CloneItem).ToList(); - var parts = engine.PackArea(remnant, cloned, progress, token); - - if (parts.Count > 0) - { - pr.Plate.Parts.AddRange(parts); - pr.Parts.AddRange(parts); - anyPlacedOnThis = true; - - foreach (var item in remaining) - { - var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - } - } - } - - // Then create new shared plates for anything still remaining. - // Fill each drawing onto shared plates one at a time, packing - // multiple drawings onto the same plate before creating a new one. - leftovers = leftovers.Where(i => i.Quantity > 0).ToList(); - - while (leftovers.Count > 0 && !token.IsCancellationRequested) - { - var plate = CreatePlate(template, plateOptions, null); - var allParts = new List(); - var anyPlacedOnPlate = false; - - // Fill each leftover drawing onto this plate. - // Recalculate remnants after each fill to avoid overlaps. - foreach (var item in leftovers) - { - if (item.Quantity <= 0 || token.IsCancellationRequested) - continue; - - // Find remaining space on the plate (recalculated each item). - var remnants = allParts.Count == 0 - ? new List { plate.WorkArea() } - : RemnantFinder.FromPlate(plate).FindRemnants(); - - if (remnants.Count == 0) - break; - - // Use only the largest remnant to avoid stale overlap issues. - var remnant = remnants[0]; - - var engine = NestEngineRegistry.Create(plate); - var clonedItem = CloneItem(item); - var parts = engine.Fill(clonedItem, remnant, progress, token); - - if (parts.Count > 0) - { - plate.Parts.AddRange(parts); - allParts.AddRange(parts); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - anyPlacedOnPlate = true; - } - } - - if (!anyPlacedOnPlate) - break; - - var pr = new PlateResult - { - Plate = plate, - Parts = allParts, - IsNew = true, - }; - - if (plateOptions != null) - { - pr.ChosenSize = plateOptions.FirstOrDefault(o => - o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length)); - } - - platePool.Add(pr); - leftovers = leftovers.Where(i => i.Quantity > 0).ToList(); - } - } - - // Anything still remaining is truly unplaced. - foreach (var item in sorted.Where(i => i.Quantity > 0)) - result.UnplacedItems.Add(item); - - result.Plates.AddRange(platePool.Where(p => p.Parts.Count > 0 || p.IsNew)); - return result; + var nester = new MultiPlateNester(template, plateOptions, salvageRate, + minRemnantSize, existingPlates, progress, token); + return nester.Run(items, sortOrder, allowPlateCreation); } - private static bool TryPlaceOnExistingPlates( - NestItem item, Box partBounds, - List platePool, Plate template, - double minRemnantSize, - IProgress progress, CancellationToken token) + // --- Private Helpers --- + + private static double ScoreZone(Box zone, Box partBounds) { - var anyPlaced = false; + var fitsNormal = zone.Length >= partBounds.Length && zone.Width >= partBounds.Width; + var fitsRotated = zone.Length >= partBounds.Width && zone.Width >= partBounds.Length; - while (item.Quantity > 0 && !token.IsCancellationRequested) - { - // Find the best zone across all plates for this item. - PlateResult bestPlate = null; - Box bestZone = null; - var bestScore = double.MinValue; + if (!fitsNormal && !fitsRotated) + return -1; - foreach (var pr in platePool) - { - if (token.IsCancellationRequested) - break; - - var workArea = pr.Plate.WorkArea(); - var classification = Classify(partBounds, workArea); - - // Small parts only go into scrap zones to preserve viable remnants. - // Medium and Large parts go into viable remnants (large parts can - // still fit in remnant space left by other large parts). - var remnants = classification == PartClass.Small - ? FindScrapZones(pr.Plate, minRemnantSize) - : FindViableRemnants(pr.Plate, minRemnantSize); - - foreach (var zone in remnants) - { - // Check normal orientation. - if (zone.Length >= partBounds.Length && zone.Width >= partBounds.Width) - { - var score = (partBounds.Length * partBounds.Width) / zone.Area(); - if (score > bestScore) - { - bestPlate = pr; - bestZone = zone; - bestScore = score; - } - } - - // Check rotated orientation. - if (zone.Length >= partBounds.Width && zone.Width >= partBounds.Length) - { - var score = (partBounds.Length * partBounds.Width) / zone.Area(); - if (score > bestScore) - { - bestPlate = pr; - bestZone = zone; - bestScore = score; - } - } - } - } - - if (bestPlate == null || bestZone == null) - break; - - // Use the engine to fill into the zone. - var engine = NestEngineRegistry.Create(bestPlate.Plate); - var clonedItem = CloneItem(item); - var parts = engine.Fill(clonedItem, bestZone, progress, token); - - if (parts.Count == 0) - break; - - bestPlate.Plate.Parts.AddRange(parts); - bestPlate.Parts.AddRange(parts); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - anyPlaced = true; - } - - return anyPlaced; + return (partBounds.Length * partBounds.Width) / zone.Area(); } - private static bool PlaceOnNewPlates( - NestItem item, Box partBounds, - List platePool, Plate template, - List plateOptions, - double minRemnantSize, - IProgress progress, CancellationToken token) + private int FillAndPlace(PlateResult pr, Box zone, NestItem item) { - var anyPlaced = false; + var engine = NestEngineRegistry.Create(pr.Plate); + var clonedItem = CloneItem(item); + var parts = engine.Fill(clonedItem, zone, _progress, _token); - while (item.Quantity > 0 && !token.IsCancellationRequested) + if (parts.Count > 0) { - var plate = CreatePlate(template, plateOptions, partBounds); - var workArea = plate.WorkArea(); - - // Can't fit on any plate we can create. - if (partBounds.Length > workArea.Length && partBounds.Length > workArea.Width) - break; - if (partBounds.Width > workArea.Width && partBounds.Width > workArea.Length) - break; - - var engine = NestEngineRegistry.Create(plate); - var clonedItem = CloneItem(item); - var parts = engine.Fill(clonedItem, workArea, progress, token); - - if (parts.Count == 0) - break; - - plate.Parts.AddRange(parts); - var pr = new PlateResult - { - Plate = plate, - IsNew = true, - }; + pr.Plate.Parts.AddRange(parts); pr.Parts.AddRange(parts); - - // Find the PlateOption used (if any). - if (plateOptions != null) - { - pr.ChosenSize = plateOptions.FirstOrDefault(o => - o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length)); - } - - platePool.Add(pr); item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - anyPlaced = true; } - return anyPlaced; + return parts.Count; } - private static bool TryUpgradeOrNewPlate( - NestItem item, Box partBounds, - List platePool, Plate template, - List plateOptions, - double salvageRate, double minRemnantSize, - IProgress progress, CancellationToken token) + private PlateResult CreateNewPlateResult(Plate plate) { - if (plateOptions == null || plateOptions.Count == 0) - return false; + var pr = new PlateResult { Plate = plate, IsNew = true }; - // Find cheapest upgrade candidate among existing new plates. - var sortedOptions = plateOptions.OrderBy(o => o.Cost).ToList(); - - foreach (var pr in platePool.Where(p => p.IsNew && p.ChosenSize != null)) + if (_plateOptions != null) { - var currentOption = pr.ChosenSize; - var currentIdx = sortedOptions.FindIndex(o => - o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length)); - - if (currentIdx < 0 || currentIdx >= sortedOptions.Count - 1) - continue; - - // Try each larger option. - for (var i = currentIdx + 1; i < sortedOptions.Count; i++) - { - var upgradeOption = sortedOptions[i]; - var smallestNew = sortedOptions.FirstOrDefault(o => - { - var ww = o.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right; - var wl = o.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom; - return (ww >= partBounds.Width && wl >= partBounds.Length) - || (ww >= partBounds.Length && wl >= partBounds.Width); - }); - - if (smallestNew == null) - continue; - - var utilEst = pr.Plate.Utilization(); - var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew, - salvageRate, utilEst); - - if (decision.ShouldUpgrade) - { - // Upgrade the plate size and re-nest with remaining items. - pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length); - pr.ChosenSize = upgradeOption; - - var engine = NestEngineRegistry.Create(pr.Plate); - var clonedItem = CloneItem(item); - var remainingArea = RemnantFinder.FromPlate(pr.Plate).FindRemnants(); - - if (remainingArea.Count > 0) - { - var parts = engine.Fill(clonedItem, remainingArea[0], progress, token); - if (parts.Count > 0) - { - pr.Plate.Parts.AddRange(parts); - pr.Parts.AddRange(parts); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - return true; - } - } - } - break; // Only try next size up. - } + pr.ChosenSize = _plateOptions.FirstOrDefault(o => + o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length)); } - return false; + return pr; } private static NestItem CloneItem(NestItem item) @@ -567,5 +239,270 @@ namespace OpenNest RotationEnd = item.RotationEnd, }; } + + private static List InitializePlatePool(List existingPlates) + { + var pool = new List(); + + if (existingPlates != null) + { + foreach (var plate in existingPlates) + pool.Add(new PlateResult { Plate = plate, IsNew = false }); + } + + return pool; + } + + // --- Orchestration --- + + private MultiPlateResult Run(List items, PartSortOrder sortOrder, bool allowPlateCreation) + { + var result = new MultiPlateResult(); + + if (items == null || items.Count == 0) + return result; + + var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder); + + foreach (var item in sorted) + { + if (_token.IsCancellationRequested || item.Quantity <= 0) + continue; + + var bb = item.Drawing.Program.BoundingBox(); + + TryPlaceOnExistingPlates(item, bb); + + var templateClass = Classify(bb, _template.WorkArea()); + + if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small) + { + PlaceOnNewPlates(item, bb); + + if (item.Quantity > 0 && _plateOptions != null && _plateOptions.Count > 0) + TryUpgradeOrNewPlate(item, bb); + } + } + + var leftovers = sorted.Where(i => i.Quantity > 0).ToList(); + + if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested) + { + PackIntoExistingRemnants(leftovers); + CreateSharedPlates(leftovers); + } + + foreach (var item in sorted.Where(i => i.Quantity > 0)) + result.UnplacedItems.Add(item); + + result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew)); + return result; + } + + private void PackIntoExistingRemnants(List leftovers) + { + foreach (var pr in _platePool) + { + if (_token.IsCancellationRequested) + break; + + var anyPlaced = true; + while (anyPlaced && !_token.IsCancellationRequested) + { + anyPlaced = false; + + var remaining = leftovers.Where(i => i.Quantity > 0).ToList(); + if (remaining.Count == 0) + break; + + var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants(); + if (remnants.Count == 0) + break; + + var engine = NestEngineRegistry.Create(pr.Plate); + var cloned = remaining.Select(CloneItem).ToList(); + var parts = engine.PackArea(remnants[0], cloned, _progress, _token); + + if (parts.Count > 0) + { + pr.Plate.Parts.AddRange(parts); + pr.Parts.AddRange(parts); + anyPlaced = true; + + foreach (var item in remaining) + { + var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + } + } + } + } + + private void CreateSharedPlates(List leftovers) + { + leftovers.RemoveAll(i => i.Quantity <= 0); + + while (leftovers.Count > 0 && !_token.IsCancellationRequested) + { + var plate = CreatePlate(_template, _plateOptions, null); + var placedAny = false; + + foreach (var item in leftovers) + { + if (item.Quantity <= 0 || _token.IsCancellationRequested) + continue; + + var remnants = !placedAny + ? new List { plate.WorkArea() } + : RemnantFinder.FromPlate(plate).FindRemnants(); + + if (remnants.Count == 0) + break; + + var engine = NestEngineRegistry.Create(plate); + var clonedItem = CloneItem(item); + var parts = engine.Fill(clonedItem, remnants[0], _progress, _token); + + if (parts.Count > 0) + { + plate.Parts.AddRange(parts); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + placedAny = true; + } + } + + if (!placedAny) + break; + + var pr = CreateNewPlateResult(plate); + pr.Parts.AddRange(plate.Parts); + _platePool.Add(pr); + leftovers.RemoveAll(i => i.Quantity <= 0); + } + } + + private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds) + { + var anyPlaced = false; + + while (item.Quantity > 0 && !_token.IsCancellationRequested) + { + PlateResult bestPlate = null; + Box bestZone = null; + var bestScore = double.MinValue; + + foreach (var pr in _platePool) + { + if (_token.IsCancellationRequested) + break; + + var workArea = pr.Plate.WorkArea(); + var classification = Classify(partBounds, workArea); + + var remnants = classification == PartClass.Small + ? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true) + : FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false); + + foreach (var zone in remnants) + { + var score = ScoreZone(zone, partBounds); + if (score > bestScore) + { + bestPlate = pr; + bestZone = zone; + bestScore = score; + } + } + } + + if (bestPlate == null || bestZone == null) + break; + + if (FillAndPlace(bestPlate, bestZone, item) == 0) + break; + + anyPlaced = true; + } + + return anyPlaced; + } + + private bool PlaceOnNewPlates(NestItem item, Box partBounds) + { + var anyPlaced = false; + + while (item.Quantity > 0 && !_token.IsCancellationRequested) + { + var plate = CreatePlate(_template, _plateOptions, partBounds); + var workArea = plate.WorkArea(); + + if (partBounds.Length > workArea.Length && partBounds.Length > workArea.Width) + break; + if (partBounds.Width > workArea.Width && partBounds.Width > workArea.Length) + break; + + var pr = CreateNewPlateResult(plate); + + if (FillAndPlace(pr, workArea, item) == 0) + break; + + _platePool.Add(pr); + anyPlaced = true; + } + + return anyPlaced; + } + + private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds) + { + if (_plateOptions == null || _plateOptions.Count == 0) + return false; + + var sortedOptions = _plateOptions.OrderBy(o => o.Cost).ToList(); + + foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null)) + { + var currentOption = pr.ChosenSize; + var currentIdx = sortedOptions.FindIndex(o => + o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length)); + + if (currentIdx < 0 || currentIdx >= sortedOptions.Count - 1) + continue; + + for (var i = currentIdx + 1; i < sortedOptions.Count; i++) + { + var upgradeOption = sortedOptions[i]; + var smallestNew = sortedOptions.FirstOrDefault(o => + { + var ww = o.Width - _template.EdgeSpacing.Left - _template.EdgeSpacing.Right; + var wl = o.Length - _template.EdgeSpacing.Top - _template.EdgeSpacing.Bottom; + return (ww >= partBounds.Width && wl >= partBounds.Length) + || (ww >= partBounds.Length && wl >= partBounds.Width); + }); + + if (smallestNew == null) + continue; + + var utilEst = pr.Plate.Utilization(); + var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew, + _salvageRate, utilEst); + + if (decision.ShouldUpgrade) + { + pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length); + pr.ChosenSize = upgradeOption; + + var remainingArea = RemnantFinder.FromPlate(pr.Plate).FindRemnants(); + + if (remainingArea.Count > 0 && FillAndPlace(pr, remainingArea[0], item) > 0) + return true; + } + break; + } + } + + return false; + } } } diff --git a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs index 2216771..7d6243c 100644 --- a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs +++ b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs @@ -140,7 +140,7 @@ public class MultiPlateNesterTests } [Fact] - public void FindScrapZones_ReturnsOnlyScrapRemnants() + public void FindRemnants_ScrapOnly_ReturnsOnlyScrapRemnants() { // 96x48 plate with a 70x40 part placed at origin var plate = new Plate(96, 48) { PartSpacing = 0.25 }; @@ -148,7 +148,7 @@ public class MultiPlateNesterTests var part = new Part(drawing); plate.Parts.Add(part); - var scrap = MultiPlateNester.FindScrapZones(plate, 12.0); + var scrap = MultiPlateNester.FindRemnants(plate, 12.0, scrapOnly: true); // All returned zones should have both dims < 12 foreach (var zone in scrap)