Files
OpenNest/OpenNest.Benchmark/Program.cs
T
ajandClaude Opus 5.5 b026bd19d2 feat(common-line): place parts on common lines automatically at a chosen level
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about
$2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot:
the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs.

- Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by
  default because customer edge-quality needs vary). The nest's CommonLineUsage
  (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded
  from Tools > Nest Defaults; both persist (nest.json, defaults.json).
- NestPipeline runs the ordinary solve, then, with a level on, trials where
  eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into
  parts plus explicit NestJobCommonLine groups, are validated again, and
  CommonLineRanking keeps a trial only by the level: count first, then sheets;
  "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a
  shorter cut or less material, never at the cost of a part. Engines, FillScore
  and their comparers are unchanged; Off asks the engine exactly what it did.
- NestLayoutCheck exempts members only through a sheet's explicit group list and
  only when the job allows common lines; materializing and committing create
  Plate.CommonLines.
- Server nesting writes common-line jobs as version 3 and replays the recorded
  trial answers, so the desktop binds the same groups.
- Fill Area honors the level through a PlateFillService overload.
- The benchmark gains --common-lines <level> --kerf <k>.

Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with
--common-lines off every engine matches the base commit; with a level on no
layout becomes invalid and three runs save a sheet at equal nested area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:00 -05:00

434 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
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.Invalid)
return 1;
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, or *.manifest.json files)."
);
return 1;
}
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
NestingEngineRegistry.LoadPlugins(enginesDir);
var engines = NestingEngineRegistry.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(
", ",
NestingEngineRegistry.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}"
);
}
if (
options.SheetSizes.Count == 0
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
)
{
Console.Error.WriteLine(
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
+ "sizes you actually stock for an unbiased comparison."
);
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
if (
effectiveSalvageRates.Any(rate => rate > 0)
&& (options.MinimumSalvageDimension ?? 0) <= 0
)
{
Console.Error.WriteLine(
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
);
}
var solves = jobs.Count * engines.Count;
if (options.Parallel > 1 && solves > 1)
{
Console.WriteLine(
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
);
}
var results = BenchmarkRunner.Run(
jobs,
engines,
options.SalvageRate,
options.MinimumSalvageDimension,
options.OutputDirectory,
options.Parallel,
options.Progress ? Console.Out : null,
options.TimeLimit,
options.CommonLines
);
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],
System.Globalization.CultureInfo.InvariantCulture
);
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 "--salvage-rate" when i + 1 < args.Length:
o.SalvageRate = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--min-salvage-dimension" when i + 1 < args.Length:
o.MinimumSalvageDimension = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--output" when i + 1 < args.Length:
o.OutputDirectory = args[++i];
break;
case "--parallel" when i + 1 < args.Length:
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
o.Parallel = parallel;
else
Console.Error.WriteLine(
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
);
break;
case "--time-limit":
o.TimeLimit = i + 1 < args.Length ? ParseTimeLimit(args[++i]) : null;
if (o.TimeLimit == null)
{
Console.Error.WriteLine(
"Error: --time-limit needs a number of seconds from 0.001 to "
+ $"{BenchmarkRunner.MaxTimeLimit.TotalSeconds:0}"
);
o.Invalid = true;
}
break;
case "--progress":
o.Progress = true;
break;
case "--common-lines" when i + 1 < args.Length:
o.CommonLineLevel = args[++i];
break;
case "--kerf" when i + 1 < args.Length:
o.Kerf = double.TryParse(args[++i], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var kerf) ? kerf : double.NaN;
break;
case "--help":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputPath = args[i];
break;
}
}
if (o.CommonLineLevel != null)
{
CommonLineUsage? usage = o.CommonLineLevel.ToLowerInvariant() switch
{
"off" => CommonLineUsage.Off,
"when-it-adds-parts" => CommonLineUsage.WhenItAddsParts,
"whenever" => CommonLineUsage.Whenever,
_ => null,
};
if (usage == null || o.Kerf is not { } kerf || !double.IsFinite(kerf) || kerf <= 0)
{
Console.Error.WriteLine(
"Error: --common-lines needs off, when-it-adds-parts or whenever, and a positive --kerf"
);
o.Invalid = true;
}
else
o.CommonLines = new CommonLineSetting(usage.Value, kerf);
}
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 (JobLoader.TryParseSheetSize(token, out var size))
sizes.Add(size);
else
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
}
return sizes.Distinct().ToList();
}
private static TimeSpan? ParseTimeLimit(string text)
{
if (
!double.TryParse(
text,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var seconds
)
|| !(seconds >= 0.001)
|| seconds > BenchmarkRunner.MaxTimeLimit.TotalSeconds
)
return null;
return TimeSpan.FromSeconds(seconds);
}
private static void PrintUsage()
{
Console.Error.WriteLine(
"OpenNest.Benchmark - compare registered whole-job 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 INestingEngine. Each engine is handed the full job - every"
);
Console.Error.WriteLine(
"requested part and the whole pool of candidate sheet sizes - and owns its own"
);
Console.Error.WriteLine(
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
);
Console.Error.WriteLine(
"demand splits across them. Ranking: a run that places every requested part beats"
);
Console.Error.WriteLine(
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
);
Console.Error.WriteLine(
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
);
Console.Error.WriteLine(
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
);
Console.Error.WriteLine(
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
);
Console.Error.WriteLine(
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
);
Console.Error.WriteLine(
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
);
Console.Error.WriteLine(
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
);
Console.Error.WriteLine(
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
);
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(
" which hints engines toward the original layout)"
);
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(
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
);
Console.Error.WriteLine(
" manifests 0; needs positive --min-salvage-dimension)"
);
Console.Error.WriteLine(
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
);
Console.Error.WriteLine(
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
);
Console.Error.WriteLine(
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
);
Console.Error.WriteLine(
" which gives the cleanest per-engine timings)"
);
Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish"
);
Console.Error.WriteLine(
" --time-limit <seconds> Stop each solve at this wall-clock limit and score"
);
Console.Error.WriteLine(
" the best valid plan reported; none = timed out"
);
Console.Error.WriteLine(
" --common-lines <level> Solve through the Auto Nest pipeline with every part allowed"
);
Console.Error.WriteLine(
" to share a common line: off, when-it-adds-parts or whenever"
);
Console.Error.WriteLine(
" (needs --kerf; off is the baseline to compare levels with)"
);
Console.Error.WriteLine(
" --kerf <value> Common-line kerf in job units (with --common-lines)"
);
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;
public string OutputDirectory;
public double? SalvageRate;
public double? MinimumSalvageDimension;
public int Parallel = 3;
public bool Progress;
public TimeSpan? TimeLimit;
public string CommonLineLevel;
public double? Kerf;
public CommonLineSetting CommonLines;
public bool Invalid;
}
}