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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<NestItem> items,
|
||||
Plate template,
|
||||
List<PlateOption> plateOptions,
|
||||
double salvageRate,
|
||||
PartSortOrder sortOrder,
|
||||
double minRemnantSize,
|
||||
bool allowPlateCreation,
|
||||
List<Plate> existingPlates,
|
||||
IProgress<NestProgress> 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<PlateResult>();
|
||||
|
||||
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<PlateResult> platePool, Plate template,
|
||||
double minRemnantSize,
|
||||
IProgress<NestProgress> 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<PlateResult> platePool, Plate template,
|
||||
List<PlateOption> plateOptions,
|
||||
double minRemnantSize,
|
||||
IProgress<NestProgress> 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<PlateResult> platePool, Plate template,
|
||||
List<PlateOption> plateOptions,
|
||||
double salvageRate, double minRemnantSize,
|
||||
IProgress<NestProgress> 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<Box> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NestItem>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<Plate> { 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user