From fd3c2462dff6bf623ba9ccc1b1cb4000002e45e8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 6 Apr 2026 14:07:24 -0400 Subject: [PATCH] feat: add MultiPlateNester.Nest orchestration method Implements the main Nest() method that ties together sorting, classification, and placement across multiple plates. The method processes items largest-first, placing medium/small parts into remnant zones on existing plates before creating new ones. Includes private helpers: TryPlaceOnExistingPlates, PlaceOnNewPlates, TryUpgradeOrNewPlate, FindAllRemnants, and CloneItem. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/MultiPlateNester.cs | 289 ++++++++++++++++++ .../Engine/MultiPlateNesterTests.cs | 122 ++++++++ 2 files changed, 411 insertions(+) diff --git a/OpenNest.Engine/MultiPlateNester.cs b/OpenNest.Engine/MultiPlateNester.cs index 8c9a84e..e380ecb 100644 --- a/OpenNest.Engine/MultiPlateNester.cs +++ b/OpenNest.Engine/MultiPlateNester.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using OpenNest.Engine.Fill; using OpenNest.Geometry; using OpenNest.Math; @@ -164,5 +166,292 @@ namespace OpenNest NewPlateCost = netNewCost, }; } + + // --- Main Orchestration --- + + public static MultiPlateResult Nest( + List items, + Plate template, + List plateOptions, + double salvageRate, + PartSortOrder sortOrder, + double minRemnantSize, + bool allowPlateCreation, + List existingPlates, + 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); + + // If items remain, try creating new plates. + if (item.Quantity > 0 && allowPlateCreation) + { + placed = PlaceOnNewPlates(item, bb, platePool, template, + plateOptions, minRemnantSize, progress, token) || placed; + } + + // If items remain, try upgrade-vs-new-plate. + if (item.Quantity > 0 && allowPlateCreation && plateOptions != null && plateOptions.Count > 0) + { + placed = TryUpgradeOrNewPlate(item, bb, platePool, template, + plateOptions, salvageRate, minRemnantSize, progress, token) || placed; + } + + if (item.Quantity > 0) + result.UnplacedItems.Add(item); + } + + result.Plates.AddRange(platePool.Where(p => p.Parts.Count > 0 || p.IsNew)); + return result; + } + + private static bool TryPlaceOnExistingPlates( + NestItem item, Box partBounds, + List platePool, Plate template, + double minRemnantSize, + IProgress progress, CancellationToken token) + { + var anyPlaced = false; + + 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; + + foreach (var pr in platePool) + { + if (token.IsCancellationRequested) + break; + + var workArea = pr.Plate.WorkArea(); + var classification = Classify(partBounds, workArea); + + if (classification == PartClass.Large) + continue; // Large parts don't go on existing plates — they create new ones. + + // Get all remnants and try to place in them. + var remnants = classification == PartClass.Small + ? FindAllRemnants(pr.Plate) + : 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; + } + + private static bool PlaceOnNewPlates( + NestItem item, Box partBounds, + List platePool, Plate template, + List plateOptions, + double minRemnantSize, + IProgress progress, CancellationToken token) + { + var anyPlaced = false; + + while (item.Quantity > 0 && !token.IsCancellationRequested) + { + 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.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; + } + + private static bool TryUpgradeOrNewPlate( + NestItem item, Box partBounds, + List platePool, Plate template, + List plateOptions, + double salvageRate, double minRemnantSize, + IProgress progress, CancellationToken token) + { + if (plateOptions == null || plateOptions.Count == 0) + return false; + + // 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)) + { + 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. + } + } + + return false; + } + + private static List FindAllRemnants(Plate plate) + { + var finder = RemnantFinder.FromPlate(plate); + return finder.FindRemnants(); + } + + private static NestItem CloneItem(NestItem item) + { + return new NestItem + { + Drawing = item.Drawing, + Priority = item.Priority, + Quantity = item.Quantity, + StepAngle = item.StepAngle, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd, + }; + } } } diff --git a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs index 30cd185..3a6ec3d 100644 --- a/OpenNest.Tests/Engine/MultiPlateNesterTests.cs +++ b/OpenNest.Tests/Engine/MultiPlateNesterTests.cs @@ -1,6 +1,7 @@ using OpenNest.Geometry; using System.Collections.Generic; using System.Linq; +using System.Threading; using Xunit; namespace OpenNest.Tests.Engine; @@ -203,4 +204,125 @@ public class MultiPlateNesterTests Assert.True(decision.ShouldUpgrade); } + + // --- Task 7: Main Orchestration --- + + [Fact] + public void Nest_LargePartsGetOwnPlates() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing(); + + var items = new List + { + MakeItem("big1", 80, 40, 1), + MakeItem("big2", 70, 35, 1), + }; + + var result = MultiPlateNester.Nest( + items, template, + plateOptions: null, + salvageRate: 0.5, + sortOrder: PartSortOrder.BoundingBoxArea, + minRemnantSize: 12.0, + allowPlateCreation: true, + existingPlates: null, + progress: null, + token: CancellationToken.None); + + // Each large part should be on its own plate. + Assert.True(result.Plates.Count >= 2, + $"Expected at least 2 plates, got {result.Plates.Count}"); + } + + [Fact] + public void Nest_SmallPartsGoIntoScrapZones() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing(); + + var items = new List + { + MakeItem("big", 80, 40, 1), + MakeItem("tiny", 5, 5, 3), + }; + + var result = MultiPlateNester.Nest( + items, template, + plateOptions: null, + salvageRate: 0.5, + sortOrder: PartSortOrder.BoundingBoxArea, + minRemnantSize: 12.0, + allowPlateCreation: true, + existingPlates: null, + progress: null, + token: CancellationToken.None); + + // Small parts should be placed on the same plate as the big part + // (in scrap zones), not on a new plate. + Assert.Equal(1, result.Plates.Count); + Assert.True(result.Plates[0].Parts.Count > 1); + } + + [Fact] + public void Nest_RespectsAllowPlateCreation() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing(); + + var items = new List + { + MakeItem("big1", 80, 40, 1), + MakeItem("big2", 70, 35, 1), + }; + + var result = MultiPlateNester.Nest( + items, template, + plateOptions: null, + salvageRate: 0.5, + sortOrder: PartSortOrder.BoundingBoxArea, + minRemnantSize: 12.0, + allowPlateCreation: false, + existingPlates: null, + progress: null, + token: CancellationToken.None); + + // No existing plates and no plate creation — nothing can be placed. + Assert.Empty(result.Plates); + Assert.Equal(2, result.UnplacedItems.Count); + } + + [Fact] + public void Nest_UsesExistingPlates() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing(); + + var existingPlate = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + existingPlate.EdgeSpacing = new Spacing(); + + // Use a part small enough to be classified as Medium on a 96x48 plate. + // Plate WorkArea: Width=96, Length=48. Half: 48, 24. + // Part 24x22: Length=24 (not > 24), Width=22 (not > 48) — not Large. + // Area = 528 > 4608/9 = 512 — Medium. + var items = new List + { + MakeItem("medium", 24, 22, 1), + }; + + var result = MultiPlateNester.Nest( + items, template, + plateOptions: null, + salvageRate: 0.5, + sortOrder: PartSortOrder.BoundingBoxArea, + minRemnantSize: 12.0, + allowPlateCreation: true, + existingPlates: new List { existingPlate }, + progress: null, + token: CancellationToken.None); + + // Part should be placed on the existing plate, not a new one. + Assert.Single(result.Plates); + Assert.False(result.Plates[0].IsNew); + } }