diff --git a/CLAUDE.md b/CLAUDE.md index a039b1d..f329952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), ## Architecture -Eight projects form a layered architecture: +Nine projects form a layered architecture: ### OpenNest.Core (class library) Domain model, geometry, and CNC primitives organized into namespaces: @@ -69,6 +69,15 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme ### 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. +### 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 ` writes a flat per-job CSV alongside the console report. + ### 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/`. diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs new file mode 100644 index 0000000..f83761d --- /dev/null +++ b/OpenNest.Benchmark/BenchmarkJob.cs @@ -0,0 +1,65 @@ +using OpenNest.Geometry; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace OpenNest.Benchmark +{ + /// + /// One request to nest a specific drawing, with the quantity and rotation + /// constraints pulled from its source .nest file. + /// + 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; } + } + + /// + /// An immutable specification for one benchmark job: a set of drawings/quantities + /// to be nested onto a plate of a given size. Every engine under test gets a fresh + /// Plate and NestItem list built from this spec via CreatePlate()/CreateItems(), + /// so one engine's run can never leak mutated state into another's. + /// + public class BenchmarkJob + { + public string SourceFile { get; init; } + public string SheetSizeLabel { get; init; } + public Size PlateSize { get; init; } + public Spacing EdgeSpacing { get; init; } + public double PartSpacing { get; init; } + public int Quadrant { get; init; } + public List Requests { get; init; } + + public string Name => $"{Path.GetFileNameWithoutExtension(SourceFile)} [{SheetSizeLabel}]"; + + public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity); + + public Plate CreatePlate() + { + return new Plate(PlateSize) + { + EdgeSpacing = EdgeSpacing, + PartSpacing = PartSpacing, + Quadrant = Quadrant, + }; + } + + public List 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(); + } + } +} diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs new file mode 100644 index 0000000..38c7dc7 --- /dev/null +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -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 +{ + /// + /// 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. + /// + public static class BenchmarkRunner + { + public static List Run(List jobs, IReadOnlyList engines) + { + var results = new List(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 parts; + + try + { + var engine = engineInfo.Factory(plate); + parts = engine.Nest(items, null, CancellationToken.None) ?? new List(); + } + 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, + }; + } + } +} diff --git a/OpenNest.Benchmark/JobLoader.cs b/OpenNest.Benchmark/JobLoader.cs new file mode 100644 index 0000000..0cff2e5 --- /dev/null +++ b/OpenNest.Benchmark/JobLoader.cs @@ -0,0 +1,136 @@ +using OpenNest.Geometry; +using OpenNest.IO; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace OpenNest.Benchmark +{ + /// + /// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on any + /// valid .nest file, using whatever drawings/quantities/plate settings it contains. + /// Optionally sweeps a fixed list of sheet sizes instead of the sizes embedded + /// in the file, so the same drawing set can be benchmarked across a standard + /// sheet-size lineup. + /// + public static class JobLoader + { + public static List Load(string inputPath, IReadOnlyList sheetSizeOverrides = null, + double? partSpacingOverride = null) + { + var files = ResolveFiles(inputPath); + var jobs = new List(); + + 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 + : ResolveSheetSizes(nest); + + foreach (var size in sizes) + { + jobs.Add(new BenchmarkJob + { + SourceFile = file, + SheetSizeLabel = size.ToString(1), + PlateSize = size, + EdgeSpacing = template.EdgeSpacing, + PartSpacing = partSpacingOverride ?? template.PartSpacing, + Quadrant = template.Quadrant, + Requests = requests, + }); + } + } + + return jobs; + } + + private static List 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 { inputPath }; + + throw new FileNotFoundException($"Benchmark input not found: {inputPath}"); + } + + private static List BuildRequests(Nest nest) + { + var requests = new List(); + + 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 ResolveSheetSizes(Nest nest) + { + var sizes = (nest.Plates ?? Enumerable.Empty()) + .Select(p => p.Size) + .Distinct() + .ToList(); + + if (sizes.Count == 0) + sizes.Add(nest.PlateDefaults.Size); + + return sizes; + } + } +} diff --git a/OpenNest.Benchmark/JobResult.cs b/OpenNest.Benchmark/JobResult.cs new file mode 100644 index 0000000..afa4298 --- /dev/null +++ b/OpenNest.Benchmark/JobResult.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace OpenNest.Benchmark +{ + /// + /// Outcome of running one engine against one job. An invalid or crashed run + /// always scores zero utilization for that job, per the benchmark rules. + /// + public class JobResult + { + public string EngineName { get; init; } + public string JobName { get; init; } + public bool Valid { get; init; } + public List 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 double UsedBoundingBoxArea { get; init; } + public long ElapsedMs { get; init; } + + public bool Crashed => Error != null; + public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested; + public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0; + } +} diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs new file mode 100644 index 0000000..934a835 --- /dev/null +++ b/OpenNest.Benchmark/NestValidator.cs @@ -0,0 +1,144 @@ +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 Violations { get; } = new(); + } + + /// + /// Validates a placed layout against the benchmark rules: every part must lie + /// within the plate's work area, every pair of parts must be at least + /// PartSpacing apart, and no drawing may have more parts placed than requested. + /// 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. + /// + public static class NestValidator + { + public static ValidationResult Validate(List parts, Plate plate, BenchmarkJob job) + { + var result = new ValidationResult(); + + if (parts == null || parts.Count == 0) + return result; + + ValidateQuantities(parts, job, result); + ValidateBounds(parts, plate, result); + ValidateSpacing(parts, plate.PartSpacing, result); + + return result; + } + + private static void ValidateQuantities(List 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} but only {max} were requested"); + } + } + } + + private static void ValidateBounds(List 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"); + } + } + } + + private static void ValidateSpacing(List 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})"); + } + } + } + } + + /// + /// 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). + /// + 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; + + var polygon = perimeter.ToPolygon(); + + if (polygon == null) + return null; + + polygon.Offset(part.Location); + return polygon; + } + } +} diff --git a/OpenNest.Benchmark/OpenNest.Benchmark.csproj b/OpenNest.Benchmark/OpenNest.Benchmark.csproj new file mode 100644 index 0000000..ccbddae --- /dev/null +++ b/OpenNest.Benchmark/OpenNest.Benchmark.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0-windows + OpenNest.Benchmark + OpenNest.Benchmark + disable + + + + + + + diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs new file mode 100644 index 0000000..b3954bb --- /dev/null +++ b/OpenNest.Benchmark/Program.cs @@ -0,0 +1,161 @@ +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 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}'"); + 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 ParseSheetSizes(string arg) + { + var sizes = new List(); + + 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("onto a fresh plate per sheet size, once per registered engine. Scoring: material"); + Console.Error.WriteLine("utilization first, then (if everything requested was placed) a smaller used"); + Console.Error.WriteLine("bounding box as the tie-break. An invalid layout (out of bounds, overlapping,"); + Console.Error.WriteLine("or over-quantity) scores zero for that job."); + Console.Error.WriteLine(); + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" OpenNest.Benchmark [options]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Sweep these plate sizes instead of each file's own"); + Console.Error.WriteLine(" --spacing Override part spacing for every job"); + Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"); + Console.Error.WriteLine(" --csv Write a flat CSV of all results"); + Console.Error.WriteLine(" --help Show this message"); + } + + private class Options + { + public string InputPath; + public List SheetSizes = new(); + public double? PartSpacing; + public List EngineNames = new(); + public string CsvPath; + } +} diff --git a/OpenNest.Benchmark/Report.cs b/OpenNest.Benchmark/Report.cs new file mode 100644 index 0000000..b2189c8 --- /dev/null +++ b/OpenNest.Benchmark/Report.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenNest.Benchmark +{ + /// + /// Console + CSV reporting for benchmark results. Ranking rule per job: + /// valid beats invalid; higher utilization wins; if utilization ties and both + /// engines fully placed every requested part, the smaller used-bounding-box + /// (more compact remnant) wins. Ties beyond that are a shared win. + /// + public static class Report + { + private const double Epsilon = 1e-6; + + public static void PrintDetailed(List results) + { + foreach (var jobGroup in results.GroupBy(r => r.JobName)) + { + Console.WriteLine(); + Console.WriteLine($"=== {jobGroup.Key} ==="); + + var ranked = jobGroup.OrderBy(r => r, Comparer.Create(Compare)).ToList(); + var best = ranked.Count > 0 ? ranked[0] : null; + + Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Remnant",-12} {"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 remnantCol = r.Valid ? $"{r.UsedBoundingBoxArea:F0}" : "-"; + var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2)); + + Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {remnantCol,-12} {r.ElapsedMs,-9} {notes}"); + } + } + } + + public static void PrintSummary(List 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), + TotalUtilization = g.Sum(r => r.Utilization), + TotalTimeMs = g.Sum(r => r.ElapsedMs), + }) + .OrderByDescending(e => e.TotalUtilization) + .ToList(); + + var wins = CountWins(results); + + Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Crashed",-8} {"Wins",-6} {"AvgUtil%",-10} {"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.Crashed,-8} {winCount,-6} {avgUtil,-10:F1} {e.TotalTimeMs,-14}"); + } + } + + public static void WriteCsv(string path, List results) + { + var sb = new StringBuilder(); + sb.AppendLine("Job,Engine,Valid,Crashed,PartsPlaced,PartsRequested,Utilization,UsedBoundingBoxArea,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.PartsPlaced, r.PartsRequested, + r.Utilization.ToString("F4", CultureInfo.InvariantCulture), + r.UsedBoundingBoxArea.ToString("F2", CultureInfo.InvariantCulture), + r.ElapsedMs, Csv(notes))); + } + + File.WriteAllText(path, sb.ToString()); + } + + 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 CountWins(List results) + { + var wins = new Dictionary(); + + foreach (var jobGroup in results.GroupBy(r => r.JobName)) + { + var ranked = jobGroup.OrderBy(r => r, Comparer.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; + } + + /// Lower sorts first (better). Valid beats invalid, then higher + /// utilization, then (if both fully placed) smaller used-bounding-box. + 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) + { + var bboxDiff = a.UsedBoundingBoxArea - b.UsedBoundingBoxArea; + + if (System.Math.Abs(bboxDiff) > Epsilon) + return bboxDiff > 0 ? 1 : -1; + } + + return 0; + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index cadc297..46cf8c0 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -176,18 +178,6 @@ Global {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.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.Build.0 = Debug|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -200,6 +190,30 @@ Global {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.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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index fbbf9cb..65440c5 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,21 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext | `--no-save` | Skip saving the output file | | `--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 ``` @@ -149,6 +164,7 @@ OpenNest.sln ├── OpenNest.Data/ # Machine configuration and cutting parameters ├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU) ├── 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.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin └── OpenNest.Tests/ # Unit tests (xUnit) @@ -166,6 +182,7 @@ OpenNest.sln | **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.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. | ## Nesting Engines