diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs index f83761d..4572242 100644 --- a/OpenNest.Benchmark/BenchmarkJob.cs +++ b/OpenNest.Benchmark/BenchmarkJob.cs @@ -20,28 +20,40 @@ namespace OpenNest.Benchmark } /// - /// 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. + /// 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. /// public class BenchmarkJob { public string SourceFile { get; init; } - public string SheetSizeLabel { get; init; } - public Size PlateSize { get; init; } + public List CandidateSizes { 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 string Name => Path.GetFileNameWithoutExtension(SourceFile); public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity); - public Plate CreatePlate() + /// + /// 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. + /// + public Plate CreateTemplatePlate() { - return new Plate(PlateSize) + var fallbackSize = CandidateSizes + .OrderByDescending(s => s.Width * s.Length) + .FirstOrDefault(); + + return new Plate(fallbackSize) { EdgeSpacing = EdgeSpacing, PartSpacing = PartSpacing, @@ -49,6 +61,19 @@ namespace OpenNest.Benchmark }; } + /// + /// 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. + /// + public List BuildPlateOptions() + { + return CandidateSizes + .Select(s => new PlateOption { Width = s.Width, Length = s.Length, Cost = s.Width * s.Length }) + .ToList(); + } + public List CreateItems() { return Requests.Select(r => new NestItem diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index 38c7dc7..90afb12 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -1,4 +1,3 @@ -using OpenNest.Geometry; using System; using System.Collections.Generic; using System.Diagnostics; @@ -8,13 +7,22 @@ using System.Threading; namespace OpenNest.Benchmark { /// - /// Runs every candidate engine against every job. Each (job, engine) pair gets - /// its own freshly-built Plate and NestItem list (via BenchmarkJob.CreatePlate/ - /// CreateItems), so no engine can see another's mutated state and no job can - /// leak partial state into the next run of the same engine. + /// Runs every candidate engine against every job. A job may need several + /// plates to place everything it asks for; this drives that loop itself, + /// since NestEngineBase.Nest() fills exactly one already-sized plate and + /// has no say in picking its own size. For each plate the loop needs, the + /// smallest candidate size that fits the largest still-unplaced drawing is + /// chosen via the codebase's own MultiPlateNester.CreatePlate, then the + /// engine's Nest() fills that plate with whatever of the remaining items + /// fit. This is applied identically to every engine, so no engine gets to + /// (or has to) implement sheet-size selection itself. /// public static class BenchmarkRunner { + /// Safety cap so a degenerate engine (placing almost nothing + /// per plate) can't loop indefinitely. + private const int MaxPlates = 40; + public static List Run(List jobs, IReadOnlyList engines) { var results = new List(jobs.Count * engines.Count); @@ -32,22 +40,58 @@ namespace OpenNest.Benchmark private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo) { - var plate = job.CreatePlate(); - var items = job.CreateItems(); + var template = job.CreateTemplatePlate(); + var options = job.BuildPlateOptions(); + var remaining = job.CreateItems(); var requested = job.TotalRequestedQuantity; + var plateRuns = new List<(Plate Plate, List Parts)>(); + string engineError = null; var sw = Stopwatch.StartNew(); - List parts; try { - var engine = engineInfo.Factory(plate); - parts = engine.Nest(items, null, CancellationToken.None) ?? new List(); + 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(); + + if (parts.Count == 0) + { + // Not even the largest available candidate size could fit + // the current largest remaining part - stop here rather + // than loop forever; whatever's left is reported unplaced. + break; + } + + plateRuns.Add((plate, parts)); + + foreach (var item in remaining) + { + var placed = parts.Count(p => p.BaseDrawing.Id == item.Drawing.Id); + + if (placed > 0) + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + } } catch (Exception ex) { - sw.Stop(); + engineError = $"{ex.GetType().Name}: {ex.Message}"; + } + sw.Stop(); + + if (engineError != null) + { return new JobResult { EngineName = engineInfo.Name, @@ -55,19 +99,19 @@ namespace OpenNest.Benchmark Valid = false, PartsRequested = requested, ElapsedMs = sw.ElapsedMilliseconds, - Error = $"{ex.GetType().Name}: {ex.Message}", + Error = engineError, }; } - sw.Stop(); + var validation = NestValidator.Validate(plateRuns, job); + var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count); + var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0; + var plateArea = plateRuns.Sum(pr => pr.Plate.Area()); - var validation = NestValidator.Validate(parts, plate, job); - - // Matches Plate.Utilization(): full sheet area, not just the cuttable - // work area, since that's what the material actually costs. - var plateArea = plate.Area(); - var placedArea = validation.Valid ? parts.Sum(p => p.BaseDrawing.Area) : 0; - var usedBox = parts.Count > 0 ? parts.GetBoundingBox() : Box.Empty; + var sizeBreakdown = plateRuns + .GroupBy(pr => pr.Plate.Size.ToString(1)) + .OrderByDescending(g => g.Count()) + .ToDictionary(g => g.Key, g => g.Count()); return new JobResult { @@ -75,13 +119,33 @@ namespace OpenNest.Benchmark JobName = job.Name, Valid = validation.Valid, Violations = validation.Violations, - PartsPlaced = parts.Count, + PartsPlaced = totalPlaced, PartsRequested = requested, PlacedArea = placedArea, PlateArea = plateArea, - UsedBoundingBoxArea = usedBox.Width * usedBox.Length, + PlatesUsed = plateRuns.Count, + SizeBreakdown = sizeBreakdown, ElapsedMs = sw.ElapsedMilliseconds, }; } + + private static double BoundsArea(NestItem item) + { + var bb = item.Drawing.Program.BoundingBox(); + return bb.Width * bb.Length; + } + + private static List CloneItems(List 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(); + } } } diff --git a/OpenNest.Benchmark/JobLoader.cs b/OpenNest.Benchmark/JobLoader.cs index 0cff2e5..7911c77 100644 --- a/OpenNest.Benchmark/JobLoader.cs +++ b/OpenNest.Benchmark/JobLoader.cs @@ -8,11 +8,12 @@ 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. + /// 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. /// public static class JobLoader { @@ -46,22 +47,18 @@ namespace OpenNest.Benchmark var template = ResolvePlateTemplate(nest); var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0 - ? sheetSizeOverrides + ? sheetSizeOverrides.ToList() : ResolveSheetSizes(nest); - foreach (var size in sizes) + jobs.Add(new BenchmarkJob { - jobs.Add(new BenchmarkJob - { - SourceFile = file, - SheetSizeLabel = size.ToString(1), - PlateSize = size, - EdgeSpacing = template.EdgeSpacing, - PartSpacing = partSpacingOverride ?? template.PartSpacing, - Quadrant = template.Quadrant, - Requests = requests, - }); - } + SourceFile = file, + CandidateSizes = sizes, + EdgeSpacing = template.EdgeSpacing, + PartSpacing = partSpacingOverride ?? template.PartSpacing, + Quadrant = template.Quadrant, + Requests = requests, + }); } return jobs; diff --git a/OpenNest.Benchmark/JobResult.cs b/OpenNest.Benchmark/JobResult.cs index afa4298..9b09599 100644 --- a/OpenNest.Benchmark/JobResult.cs +++ b/OpenNest.Benchmark/JobResult.cs @@ -3,8 +3,11 @@ 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. + /// 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. /// public class JobResult { @@ -17,11 +20,16 @@ namespace OpenNest.Benchmark public int PartsRequested { get; init; } public double PlacedArea { get; init; } public double PlateArea { get; init; } - public double UsedBoundingBoxArea { get; init; } + public int PlatesUsed { get; init; } + public Dictionary SizeBreakdown { get; init; } = new(); public long ElapsedMs { get; init; } public bool Crashed => Error != null; public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested; + + /// 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. public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0; } } diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index 4b0b276..baec8cc 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -13,52 +13,41 @@ namespace OpenNest.Benchmark } /// - /// 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. + /// 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. /// public static class NestValidator { - public static ValidationResult Validate(List parts, Plate plate, BenchmarkJob job) + public static ValidationResult Validate(List<(Plate Plate, List Parts)> plateRuns, BenchmarkJob job) { var result = new ValidationResult(); + var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList(); - if (parts == null || parts.Count == 0) + if (allParts.Count == 0) return result; - ValidateQuantities(parts, job, result); - ValidateBounds(parts, plate, result); - ValidateAreaBudget(parts, plate, result); - ValidateSpacing(parts, plate.PartSpacing, 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; } - /// - /// 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. - /// - private static void ValidateAreaBudget(List 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}) exceeds the work area ({budget:F2}) - " + - "parts must overlap even though the polygon overlap check did not flag a pair"); - } - } - private static void ValidateQuantities(List parts, BenchmarkJob job, ValidationResult result) { var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity); @@ -77,7 +66,7 @@ namespace OpenNest.Benchmark 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"); + result.Violations.Add($"'{name}': placed {placed} across all plates but only {max} were requested"); } } } @@ -98,11 +87,35 @@ namespace OpenNest.Benchmark 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"); + $"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " + + $"of a {plate.Size} plate"); } } } + /// + /// 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. + /// + private static void ValidateAreaBudget(List 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 parts, double spacing, ValidationResult result) { var worldPolygons = new Polygon[parts.Count]; diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs index b3954bb..4a40bc1 100644 --- a/OpenNest.Benchmark/Program.cs +++ b/OpenNest.Benchmark/Program.cs @@ -57,6 +57,13 @@ static class BenchmarkConsole } 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); @@ -133,17 +140,21 @@ static class BenchmarkConsole { 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("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 [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(" --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 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"); diff --git a/OpenNest.Benchmark/Report.cs b/OpenNest.Benchmark/Report.cs index b2189c8..12fcc21 100644 --- a/OpenNest.Benchmark/Report.cs +++ b/OpenNest.Benchmark/Report.cs @@ -9,9 +9,10 @@ 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. + /// 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. /// public static class Report { @@ -27,7 +28,7 @@ namespace OpenNest.Benchmark 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"); + Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes"); foreach (var r in ranked) { @@ -36,10 +37,10 @@ namespace OpenNest.Benchmark 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 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} {remnantCol,-12} {r.ElapsedMs,-9} {notes}"); + Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}"); } } } @@ -57,7 +58,9 @@ namespace OpenNest.Benchmark 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) @@ -65,35 +68,43 @@ namespace OpenNest.Benchmark var wins = CountWins(results); - Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Crashed",-8} {"Wins",-6} {"AvgUtil%",-10} {"TotalTime(ms)",-14}"); + 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.Crashed,-8} {winCount,-6} {avgUtil,-10:F1} {e.TotalTimeMs,-14}"); + 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 results) { var sb = new StringBuilder(); - sb.AppendLine("Job,Engine,Valid,Crashed,PartsPlaced,PartsRequested,Utilization,UsedBoundingBoxArea,ElapsedMs,Notes"); + 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, + Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced, r.PartsPlaced, r.PartsRequested, r.Utilization.ToString("F4", CultureInfo.InvariantCulture), - r.UsedBoundingBoxArea.ToString("F2", CultureInfo.InvariantCulture), + r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)), r.ElapsedMs, Csv(notes))); } File.WriteAllText(path, sb.ToString()); } + private static string SizeSummary(Dictionary 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)) @@ -124,7 +135,7 @@ namespace OpenNest.Benchmark } /// Lower sorts first (better). Valid beats invalid, then higher - /// utilization, then (if both fully placed) smaller used-bounding-box. + /// aggregate utilization, then (if both fully placed) fewer plates used. private static int Compare(JobResult a, JobResult b) { if (a.Valid != b.Valid) @@ -138,13 +149,8 @@ namespace OpenNest.Benchmark 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; - } + if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed) + return a.PlatesUsed > b.PlatesUsed ? 1 : -1; return 0; }