Compare commits

...
4 Commits
Author SHA1 Message Date
aj f0fe79f0f1 Merge remote-tracking branch 'origin/master' 2026-09-19 07:44:40 -04:00
aj a9e0f8a1d4 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.
2026-09-15 21:36:31 -04:00
aj 20da5477b6 Fix NestValidator: add area-budget backstop, cheaper polygon conversion
Collision.HasOverlap (and Part.Intersects, which shares the same
underlying algorithm) was observed to return false negatives on real,
complex production geometry: a layout with a combined placed area over
7x the plate's work area passed the polygon-based spacing/overlap
check with zero flagged pairs. This is a pre-existing gap in
OpenNest.Core's Collision detection, not something introduced here,
but it let an obviously-invalid layout score as "valid".

ValidateAreaBudget adds a hard mathematical backstop that does not
depend on Collision at all: non-overlapping parts confined to the work
area can never have a combined area greater than the work area itself.

Also switch WorldPolygon from Shape.ToPolygon() (default up to 1000
segments per arc) to ToPolygonWithTolerance(0.01), matching the
convention already used elsewhere in the codebase (e.g.
BestFit.PolygonHelper) - arc-heavy real parts were producing
thousands-of-vertex polygons for a simple spacing check.
2026-09-15 20:54:12 -04:00
aj 6a0fba0fec 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.
2026-09-15 18:31:36 -04:00
11 changed files with 988 additions and 13 deletions
+10 -1
View File
@@ -20,7 +20,7 @@ NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO),
## Architecture ## Architecture
Eight projects form a layered architecture: Nine projects form a layered architecture:
### OpenNest.Core (class library) ### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces: Domain model, geometry, and CNC primitives organized into namespaces:
@@ -73,6 +73,15 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme
### OpenNest.Training (console app, depends on Core + Engine) ### OpenNest.Training (console app, depends on Core + Engine)
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training. Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
Compares registered `NestEngineBase` implementations against each other on real `.nest` files. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
- `BenchmarkRunner` gives each (job, engine) pair a fresh `Plate`/`NestItem` list (`BenchmarkJob.CreatePlate()`/`CreateItems()`) so engines can't see each other's mutated state, then calls the engine's `Nest()` and times it.
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically via each part's own world-space polygon, inflated by the spacing — works on arbitrary concave/holed shapes, not just bounding boxes), and no drawing over its requested quantity. An invalid or throwing run scores zero for that job.
- Scoring matches `Plate.Utilization()` (placed drawing area / full sheet area, `Plate.Area()`). If an engine placed every requested part, ties are broken by the smaller used-bounding-box (`Report`'s ranking rule) — a more compact layout leaves a bigger usable remnant.
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
### OpenNest.Mcp (console app, depends on Core + Engine + IO) ### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
+90
View File
@@ -0,0 +1,90 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.Benchmark
{
/// <summary>
/// One request to nest a specific drawing, with the quantity and rotation
/// constraints pulled from its source .nest file.
/// </summary>
public class DrawingRequest
{
public Drawing Drawing { get; init; }
public int Quantity { get; init; }
public int Priority { get; init; }
public double StepAngle { get; init; }
public double RotationStart { get; init; }
public double RotationEnd { get; init; }
}
/// <summary>
/// An immutable specification for one benchmark job: the full set of
/// drawings/quantities that must be nested, and the pool of sheet sizes the
/// engine may draw from while doing it. A single run may use several
/// plates - possibly of different sizes - to place everything, the same
/// way a real production job spreads across whatever plates it needs
/// rather than being handed one fixed-size sheet.
/// </summary>
public class BenchmarkJob
{
public string SourceFile { get; init; }
public List<Size> CandidateSizes { get; init; }
public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
public List<DrawingRequest> Requests { get; init; }
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
/// <summary>
/// A blank plate carrying only the job's spacing/quadrant template.
/// MultiPlateNester.CreatePlate copies these settings onto whichever
/// size it ultimately picks; its Size is only the fallback used when
/// nothing in the candidate pool fits, so it's set to the largest
/// candidate rather than an arbitrary one.
/// </summary>
public Plate CreateTemplatePlate()
{
var fallbackSize = CandidateSizes
.OrderByDescending(s => s.Width * s.Length)
.FirstOrDefault();
return new Plate(fallbackSize)
{
EdgeSpacing = EdgeSpacing,
PartSpacing = PartSpacing,
Quadrant = Quadrant,
};
}
/// <summary>
/// The candidate sizes as PlateOptions for MultiPlateNester.CreatePlate.
/// Cost is area-proportional since no real per-size material pricing is
/// available here - this only affects which size is preferred when more
/// than one candidate fits, favoring the smaller/cheaper sheet.
/// </summary>
public List<PlateOption> BuildPlateOptions()
{
return CandidateSizes
.Select(s => new PlateOption { Width = s.Width, Length = s.Length, Cost = s.Width * s.Length })
.ToList();
}
public List<NestItem> CreateItems()
{
return Requests.Select(r => new NestItem
{
Drawing = r.Drawing,
Quantity = r.Quantity,
Priority = r.Priority,
StepAngle = r.StepAngle,
RotationStart = r.RotationStart,
RotationEnd = r.RotationEnd,
}).ToList();
}
}
}
+151
View File
@@ -0,0 +1,151 @@
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. 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);
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 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();
try
{
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)
{
engineError = $"{ex.GetType().Name}: {ex.Message}";
}
sw.Stop();
if (engineError != null)
{
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
ElapsedMs = sw.ElapsedMilliseconds,
Error = engineError,
};
}
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 sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = totalPlaced,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
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();
}
}
}
+133
View File
@@ -0,0 +1,133 @@
using OpenNest.Geometry;
using OpenNest.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
/// any valid .nest file, using whatever drawings/quantities/plate settings
/// it contains. One job per file, carrying the full pool of candidate
/// sheet sizes the engine may use across the whole nest - by default the
/// distinct sizes already present in that file, or a fixed override list
/// (e.g. a standard sheet-size lineup) applied to every file.
/// </summary>
public static class JobLoader
{
public static List<BenchmarkJob> Load(string inputPath, IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null)
{
var files = ResolveFiles(inputPath);
var jobs = new List<BenchmarkJob>();
foreach (var file in files)
{
Nest nest;
try
{
nest = new NestReader(file).Read();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': failed to read ({ex.Message})");
continue;
}
var requests = BuildRequests(nest);
if (requests.Count == 0)
{
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': no drawings with quantity > 0");
continue;
}
var template = ResolvePlateTemplate(nest);
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
? sheetSizeOverrides.ToList()
: ResolveSheetSizes(nest);
jobs.Add(new BenchmarkJob
{
SourceFile = file,
CandidateSizes = sizes,
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
Requests = requests,
});
}
return jobs;
}
private static List<string> ResolveFiles(string inputPath)
{
if (Directory.Exists(inputPath))
{
return Directory.GetFiles(inputPath, "*.nest", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (File.Exists(inputPath))
return new List<string> { inputPath };
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
}
private static List<DrawingRequest> BuildRequests(Nest nest)
{
var requests = new List<DrawingRequest>();
foreach (var drawing in nest.Drawings)
{
var qty = drawing.Quantity.Required;
if (qty <= 0)
continue;
var constraints = drawing.Constraints;
requests.Add(new DrawingRequest
{
Drawing = drawing,
Quantity = qty,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
});
}
return requests;
}
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(Nest nest)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
}
private static List<Size> ResolveSheetSizes(Nest nest)
{
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
.Select(p => p.Size)
.Distinct()
.ToList();
if (sizes.Count == 0)
sizes.Add(nest.PlateDefaults.Size);
return sizes;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace OpenNest.Benchmark
{
/// <summary>
/// Outcome of running one engine against one job. A job may span several
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
/// one sheet - possibly of different sizes - to place everything asked of
/// it. An invalid or crashed run always scores zero utilization, per the
/// benchmark rules.
/// </summary>
public class JobResult
{
public string EngineName { get; init; }
public string JobName { get; init; }
public bool Valid { get; init; }
public List<string> Violations { get; init; } = new();
public string Error { get; init; }
public int PartsPlaced { get; init; }
public int PartsRequested { get; init; }
public double PlacedArea { get; init; }
public double PlateArea { get; init; }
public int PlatesUsed { get; init; }
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
public long ElapsedMs { get; init; }
public bool Crashed => Error != null;
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
/// <summary>Aggregate utilization across every plate the engine used:
/// total placed drawing area over total plate area, matching
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
}
}
+183
View File
@@ -0,0 +1,183 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Benchmark
{
public class ValidationResult
{
public bool Valid => Violations.Count == 0;
public List<string> Violations { get; } = new();
}
/// <summary>
/// Validates a (possibly multi-plate) placed layout against the benchmark
/// rules: on every plate, every part must lie within that plate's work
/// area and every pair of parts must be at least PartSpacing apart; across
/// all plates combined, no drawing may have more parts placed than
/// requested (the quantity limit is a property of the whole order, not of
/// any one plate). Geometry checks work on arbitrary (concave, holed)
/// polygons by reusing the same world-space extraction Part.Intersects
/// uses internally, so no engine gets an advantage or penalty from shape
/// complexity.
/// </summary>
public static class NestValidator
{
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns, BenchmarkJob job)
{
var result = new ValidationResult();
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
if (allParts.Count == 0)
return result;
ValidateQuantities(allParts, job, result);
foreach (var (plate, parts) in plateRuns)
{
if (parts.Count == 0)
continue;
ValidateBounds(parts, plate, result);
ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, result);
}
return result;
}
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
{
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
var placedCounts = parts
.GroupBy(p => p.BaseDrawing.Id)
.ToDictionary(g => g.Key, g => g.Count());
foreach (var (drawingId, placed) in placedCounts)
{
if (!allowed.TryGetValue(drawingId, out var max))
{
result.Violations.Add($"Placed drawing id={drawingId} which was not requested for this job");
continue;
}
if (placed > max)
{
var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name;
result.Violations.Add($"'{name}': placed {placed} across all plates but only {max} were requested");
}
}
}
private static void ValidateBounds(List<Part> parts, Plate plate, ValidationResult result)
{
var workArea = plate.WorkArea();
foreach (var part in parts)
{
var bb = part.BoundingBox;
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
if (outLeft || outBottom || outRight || outTop)
{
result.Violations.Add(
$"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
$"of a {plate.Size} plate");
}
}
}
/// <summary>
/// Hard mathematical backstop: non-overlapping parts confined to the
/// work area can never have a combined area greater than the work
/// area itself. This catches overlap that the polygon-based
/// ValidateSpacing check can miss - Collision.HasOverlap (and
/// Part.Intersects, which uses the same algorithm) has been observed
/// to return false negatives on real, complex production geometry, so
/// this check does not depend on it.
/// </summary>
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
{
var workArea = plate.WorkArea();
var budget = workArea.Width * workArea.Length;
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
{
result.Violations.Add(
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " +
"parts must overlap even though the polygon overlap check did not flag a pair");
}
}
private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result)
{
var worldPolygons = new Polygon[parts.Count];
var inflatedPolygons = new Polygon[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
worldPolygons[i] = WorldPolygon(parts[i], 0);
inflatedPolygons[i] = spacing > Tolerance.Epsilon ? WorldPolygon(parts[i], spacing) : worldPolygons[i];
}
for (var i = 0; i < parts.Count; i++)
{
if (worldPolygons[i] == null || inflatedPolygons[i] == null)
continue;
for (var j = i + 1; j < parts.Count; j++)
{
if (worldPolygons[j] == null)
continue;
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
{
result.Violations.Add(
$"'{parts[i].BaseDrawing.Name}' and '{parts[j].BaseDrawing.Name}' are closer than the required spacing ({spacing:F3})");
}
}
}
}
/// <summary>
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
/// outward by the given spacing, mirroring Part.Intersects' own geometry
/// extraction (part.Program is already rotated; only a Location offset is needed).
/// </summary>
private static Polygon WorldPolygon(Part part, double inflateBy)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var perimeter = new ShapeProfile(entities).Perimeter;
if (perimeter == null)
return null;
if (inflateBy > Tolerance.Epsilon)
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
// segments per arc) - arc-heavy real parts otherwise produce thousands
// of vertices, which is needlessly slow for a spacing check.
var polygon = perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon == null)
return null;
polygon.Offset(part.Location);
return polygon;
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Benchmark</RootNamespace>
<AssemblyName>OpenNest.Benchmark</AssemblyName>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+172
View File
@@ -0,0 +1,172 @@
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
return BenchmarkConsole.Run(args);
static class BenchmarkConsole
{
public static int Run(string[] args)
{
var options = ParseArgs(args);
if (options == null)
return 0; // --help was requested
if (options.InputPath == null)
{
PrintUsage();
return 1;
}
List<BenchmarkJob> jobs;
try
{
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
if (jobs.Count == 0)
{
Console.Error.WriteLine("No benchmark jobs found (no .nest files with any drawing quantity > 0).");
return 1;
}
var engines = NestEngineRegistry.AvailableEngines;
if (options.EngineNames.Count > 0)
{
engines = engines
.Where(e => options.EngineNames.Any(n => n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (engines.Count == 0)
{
Console.Error.WriteLine("None of the requested engines are registered. Available: " +
string.Join(", ", NestEngineRegistry.AvailableEngines.Select(e => e.Name)));
return 1;
}
}
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
foreach (var job in jobs)
{
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
Console.WriteLine($" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}");
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var results = BenchmarkRunner.Run(jobs, engines);
Report.PrintDetailed(results);
Report.PrintSummary(results);
if (options.CsvPath != null)
{
Report.WriteCsv(options.CsvPath, results);
Console.WriteLine();
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
}
return 0;
}
private static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sheet-sizes" when i + 1 < args.Length:
o.SheetSizes = ParseSheetSizes(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.PartSpacing = double.Parse(args[++i]);
break;
case "--engines" when i + 1 < args.Length:
o.EngineNames = args[++i]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
break;
case "--csv" when i + 1 < args.Length:
o.CsvPath = args[++i];
break;
case "--help":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputPath = args[i];
break;
}
}
return o;
}
private static List<Size> ParseSheetSizes(string arg)
{
var sizes = new List<Size>();
foreach (var token in arg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Size.TryParse(token, out var size))
sizes.Add(size);
else
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
}
return sizes;
}
private static void PrintUsage()
{
Console.Error.WriteLine("OpenNest.Benchmark - compare registered nesting engines on a set of .nest files");
Console.Error.WriteLine();
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
Console.Error.WriteLine("once per registered engine. This is a full nest, not a single fixed-size plate:");
Console.Error.WriteLine("as many plates as needed are created, one at a time, each sized by picking the");
Console.Error.WriteLine("smallest candidate sheet size that fits the largest still-unplaced drawing -");
Console.Error.WriteLine("applied identically to every engine, since Nest() itself has no say over its");
Console.Error.WriteLine("own plate's size. Scoring: aggregate material utilization across every plate");
Console.Error.WriteLine("used, then (if everything requested was placed) fewer plates as the tie-break.");
Console.Error.WriteLine("An invalid layout (out of bounds, overlapping, or over-quantity) scores zero.");
Console.Error.WriteLine();
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
Console.Error.WriteLine(" --help Show this message");
}
private class Options
{
public string InputPath;
public List<Size> SheetSizes = new();
public double? PartSpacing;
public List<string> EngineNames = new();
public string CsvPath;
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.Benchmark
{
/// <summary>
/// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; higher aggregate utilization wins; if utilization
/// ties and both engines fully placed every requested part, fewer plates
/// used wins (the multi-plate analogue of "smaller remnant" - both are
/// proxies for wasting less material). Ties beyond that are a shared win.
/// </summary>
public static class Report
{
private const double Epsilon = 1e-6;
public static void PrintDetailed(List<JobResult> results)
{
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
Console.WriteLine();
Console.WriteLine($"=== {jobGroup.Key} ===");
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes");
foreach (var r in ranked)
{
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
var marker = isWinner ? "*" : " ";
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var platesCol = r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}");
}
}
}
public static void PrintSummary(List<JobResult> results)
{
Console.WriteLine();
Console.WriteLine("=== Summary ===");
var byEngine = results
.GroupBy(r => r.EngineName)
.Select(g => new
{
Engine = g.Key,
Jobs = g.Count(),
Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
TotalUtilization = g.Sum(r => r.Utilization),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs),
})
.OrderByDescending(e => e.TotalUtilization)
.ToList();
var wins = CountWins(results);
Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Complete",-9} {"Wins",-6} {"AvgUtil%",-10} {"Plates",-8} {"TotalTime(ms)",-14}");
foreach (var e in byEngine)
{
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.FullyPlaced,-9} {winCount,-6} {avgUtil,-10:F1} {e.TotalPlates,-8} {e.TotalTimeMs,-14}");
}
}
public static void WriteCsv(string path, List<JobResult> results)
{
var sb = new StringBuilder();
sb.AppendLine("Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes");
foreach (var r in results)
{
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(string.Join(",",
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
r.PartsPlaced, r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs, Csv(notes)));
}
File.WriteAllText(path, sb.ToString());
}
private static string SizeSummary(Dictionary<string, int> breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static Dictionary<string, int> CountWins(List<JobResult> results)
{
var wins = new Dictionary<string, int>();
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
if (ranked.Count == 0 || !ranked[0].Valid)
continue;
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
}
return wins;
}
/// <summary>Lower sorts first (better). Valid beats invalid, then higher
/// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
private static int Compare(JobResult a, JobResult b)
{
if (a.Valid != b.Valid)
return a.Valid ? -1 : 1;
if (!a.Valid)
return 0;
var utilDiff = b.Utilization - a.Utilization;
if (System.Math.Abs(utilDiff) > Epsilon)
return utilDiff > 0 ? 1 : -1;
if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
return 0;
}
}
}
+25 -12
View File
@@ -34,6 +34,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}"
EndProject EndProject
Global Global
@@ -178,18 +179,6 @@ Global
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU {FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU {FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU {FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -202,6 +191,30 @@ Global
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.ActiveCfg = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.Build.0 = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.ActiveCfg = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.Build.0 = Debug|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.Build.0 = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.ActiveCfg = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.Build.0 = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.ActiveCfg = Release|Any CPU
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.Build.0 = Release|Any CPU
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU
+17
View File
@@ -166,6 +166,21 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
| `--no-save` | Skip saving the output file | | `--no-save` | Skip saving the output file |
| `--no-log` | Skip writing the debug log | | `--no-log` | Skip writing the debug log |
## Benchmarking Nest Engines
`OpenNest.Benchmark` compares every registered `NestEngineBase` implementation against each other on a set of `.nest` files, scoring by material utilization:
```bash
# Benchmark all registered engines against every .nest file in a folder
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- ./benchmark-jobs
# Sweep a fixed list of sheet sizes instead of each file's own, limit to specific engines
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- job.nest \
--sheet-sizes 48x96,60x96,60x120,72x120,72x144 --engines Default,Astra,Claude --csv results.csv
```
An engine's layout is rejected (scoring zero for that job) if any part falls outside the work area, any two parts are closer than the required spacing, or a drawing gets more parts placed than requested.
## Project Structure ## Project Structure
``` ```
@@ -180,6 +195,7 @@ OpenNest.sln
├── OpenNest.Data/ # Machine configuration and cutting parameters ├── OpenNest.Data/ # Machine configuration and cutting parameters
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU) ├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core) ├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
├── OpenNest.Benchmark/ # Head-to-head comparison of registered nest engines
├── OpenNest.Mcp/ # MCP server for AI tool integration ├── OpenNest.Mcp/ # MCP server for AI tool integration
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin ├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
└── OpenNest.Tests/ # Unit tests (xUnit) └── OpenNest.Tests/ # Unit tests (xUnit)
@@ -197,6 +213,7 @@ OpenNest.sln
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. | | **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. | | **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. | | **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
| **OpenNest.Benchmark** | Runs every registered nest engine against a set of `.nest` files and scores them by material utilization, so engine implementations can be compared head-to-head. |
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. | | **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
## Nesting Engines ## Nesting Engines