Rework OpenNest.Benchmark into a full multi-plate, multi-size nest

Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

NestEngineBase.Nest() has no way to pick its own plate's size - it
fills whatever Plate it's given - so size selection now lives in the
harness itself, applied identically to every engine:

- BenchmarkJob carries the full candidate size pool (CandidateSizes)
  instead of one fixed PlateSize; one job per file, not one per size.
- BenchmarkRunner drives a loop: while items remain, pick the smallest
  candidate size that fits the largest still-unplaced drawing (reusing
  the codebase's own MultiPlateNester.CreatePlate/FitsBounds), build a
  fresh plate of that size, and run one Nest() call to fill it. Repeat
  until everything is placed, no candidate size fits what's left, or a
  safety cap (40 plates) is hit.
- NestValidator now validates bounds/spacing per plate but the
  quantity cap once globally across all plates, since that limit
  belongs to the whole order, not any one sheet.
- JobResult/Report report PlatesUsed and a per-size breakdown instead
  of a single-plate bounding-box compactness metric; utilization is
  now aggregated across every plate the engine used. Ranking keeps the
  same rule (utilization first), with fewer plates as the tie-break
  when both are fully placed and tied - the natural multi-plate
  analogue of the old single-plate compactness tie-break.

Smoke-tested against the synthetic sample across 5 candidate sizes:
correctly builds one job, picks the smallest fitting size, uses
however many plates each engine needs (1-2 here), and still catches
StripNestEngine's pre-existing out-of-bounds bug.
This commit is contained in:
aj
2026-09-15 21:36:31 -04:00
parent 20da5477b6
commit a9e0f8a1d4
7 changed files with 237 additions and 113 deletions
+86 -22
View File
@@ -1,4 +1,3 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -8,13 +7,22 @@ using System.Threading;
namespace OpenNest.Benchmark
{
/// <summary>
/// Runs every candidate engine against every job. Each (job, engine) pair gets
/// its own freshly-built Plate and NestItem list (via BenchmarkJob.CreatePlate/
/// CreateItems), so no engine can see another's mutated state and no job can
/// leak partial state into the next run of the same engine.
/// Runs every candidate engine against every job. A job may need several
/// plates to place everything it asks for; this drives that loop itself,
/// since NestEngineBase.Nest() fills exactly one already-sized plate and
/// has no say in picking its own size. For each plate the loop needs, the
/// smallest candidate size that fits the largest still-unplaced drawing is
/// chosen via the codebase's own MultiPlateNester.CreatePlate, then the
/// engine's Nest() fills that plate with whatever of the remaining items
/// fit. This is applied identically to every engine, so no engine gets to
/// (or has to) implement sheet-size selection itself.
/// </summary>
public static class BenchmarkRunner
{
/// <summary>Safety cap so a degenerate engine (placing almost nothing
/// per plate) can't loop indefinitely.</summary>
private const int MaxPlates = 40;
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines)
{
var results = new List<JobResult>(jobs.Count * engines.Count);
@@ -32,22 +40,58 @@ namespace OpenNest.Benchmark
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
{
var plate = job.CreatePlate();
var items = job.CreateItems();
var template = job.CreateTemplatePlate();
var options = job.BuildPlateOptions();
var remaining = job.CreateItems();
var requested = job.TotalRequestedQuantity;
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
string engineError = null;
var sw = Stopwatch.StartNew();
List<Part> parts;
try
{
var engine = engineInfo.Factory(plate);
parts = engine.Nest(items, null, CancellationToken.None) ?? new List<Part>();
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates)
{
var largest = remaining
.Where(i => i.Quantity > 0)
.OrderByDescending(i => BoundsArea(i))
.First();
var plate = MultiPlateNester.CreatePlate(template, options, largest.Drawing.Program.BoundingBox());
var engine = engineInfo.Factory(plate);
var itemsClone = CloneItems(remaining);
var parts = engine.Nest(itemsClone, null, CancellationToken.None) ?? new List<Part>();
if (parts.Count == 0)
{
// Not even the largest available candidate size could fit
// the current largest remaining part - stop here rather
// than loop forever; whatever's left is reported unplaced.
break;
}
plateRuns.Add((plate, parts));
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing.Id == item.Drawing.Id);
if (placed > 0)
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
}
}
catch (Exception ex)
{
sw.Stop();
engineError = $"{ex.GetType().Name}: {ex.Message}";
}
sw.Stop();
if (engineError != null)
{
return new JobResult
{
EngineName = engineInfo.Name,
@@ -55,19 +99,19 @@ namespace OpenNest.Benchmark
Valid = false,
PartsRequested = requested,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
Error = engineError,
};
}
sw.Stop();
var validation = NestValidator.Validate(plateRuns, job);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
var validation = NestValidator.Validate(parts, plate, job);
// Matches Plate.Utilization(): full sheet area, not just the cuttable
// work area, since that's what the material actually costs.
var plateArea = plate.Area();
var placedArea = validation.Valid ? parts.Sum(p => p.BaseDrawing.Area) : 0;
var usedBox = parts.Count > 0 ? parts.GetBoundingBox() : Box.Empty;
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
return new JobResult
{
@@ -75,13 +119,33 @@ namespace OpenNest.Benchmark
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = parts.Count,
PartsPlaced = totalPlaced,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
UsedBoundingBoxArea = usedBox.Width * usedBox.Length,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
};
}
private static double BoundsArea(NestItem item)
{
var bb = item.Drawing.Program.BoundingBox();
return bb.Width * bb.Length;
}
private static List<NestItem> CloneItems(List<NestItem> items)
{
return items.Select(i => new NestItem
{
Drawing = i.Drawing,
Quantity = i.Quantity,
Priority = i.Priority,
StepAngle = i.StepAngle,
RotationStart = i.RotationStart,
RotationEnd = i.RotationEnd,
}).ToList();
}
}
}