Add OpenNest.Benchmark: generic head-to-head engine comparison harness

Loads any .nest file (or folder of them) via NestReader and nests every
drawing with quantity > 0 using each registered NestEngineBase, so it
works sight-unseen against arbitrary real jobs without any hardcoded
geometry. Optionally sweeps a fixed --sheet-sizes list instead of each
file's own plate size.

- BenchmarkJob/JobLoader build immutable job specs; a fresh Plate and
  NestItem list is created per (job, engine) run so state never leaks
  between engines or jobs.
- NestValidator rejects a layout if any part falls outside the work
  area, any two parts are closer than PartSpacing (checked via each
  part's own world-space polygon inflated by the spacing, so it holds
  for arbitrary concave/holed geometry, not just bounding boxes), or a
  drawing gets more parts than requested.
- Scoring matches Plate.Utilization() (placed area / full sheet area);
  ties among fully-placed layouts break on the smaller used bounding
  box (more usable remnant).
- Report prints a per-job ranked breakdown plus a per-engine summary
  (wins, avg utilization, time), and can write a flat CSV.

Verified end-to-end against a synthetic .nest file (not committed)
against the four built-in engines; caught a genuine out-of-work-area
bug in StripNestEngine in the process.
This commit is contained in:
aj
2026-09-15 18:31:36 -04:00
parent 587000f68a
commit 6a0fba0fec
11 changed files with 839 additions and 13 deletions
+87
View File
@@ -0,0 +1,87 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
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.
/// </summary>
public static class BenchmarkRunner
{
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines)
{
var results = new List<JobResult>(jobs.Count * engines.Count);
foreach (var job in jobs)
{
foreach (var engineInfo in engines)
{
results.Add(RunOne(job, engineInfo));
}
}
return results;
}
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
{
var plate = job.CreatePlate();
var items = job.CreateItems();
var requested = job.TotalRequestedQuantity;
var sw = Stopwatch.StartNew();
List<Part> parts;
try
{
var engine = engineInfo.Factory(plate);
parts = engine.Nest(items, null, CancellationToken.None) ?? new List<Part>();
}
catch (Exception ex)
{
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
sw.Stop();
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;
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = parts.Count,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
UsedBoundingBoxArea = usedBox.Width * usedBox.Length,
ElapsedMs = sw.ElapsedMilliseconds,
};
}
}
}