diff --git a/CLAUDE.md b/CLAUDE.md index 6eca997..8403c3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,8 @@ Compares registered `INestingEngine` implementations against each other on real - `DxfManifestLoader` builds a `BenchmarkJob` from a JSON manifest (`sheetSizes`, `spacing`, `edgeSpacing`, `quadrant`, `parts[] { dxf, quantity, allowRotation }`) instead of a `.nest`, importing each DXF with `CadImporter.ImportDrawing`. DXF paths resolve relative to the manifest; sheet sizes are required (manifest or `--sheet-sizes`, which overrides). `allowRotation: false` locks rotation the same way `NestRunner` does. `JobLoader.Load` routes `*.json` inputs to it, and folder scans pick up `*.nest` plus `*.manifest.json` (plain `*.json` is ignored so `--output` reports are never read as manifests). Invalid manifests throw rather than being skipped. - `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses). - `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring. -- `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, throwing, or timed-out 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 fewer plates used (`Report`'s ranking rule) — using fewer sheets to do the same job wastes less material. +- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring. +- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`). - `--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) diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs index 9496610..2a70855 100644 --- a/OpenNest.Benchmark/BenchmarkJob.cs +++ b/OpenNest.Benchmark/BenchmarkJob.cs @@ -42,6 +42,12 @@ namespace OpenNest.Benchmark public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity); + /// Sheet area charged per unplaced part: the largest candidate + /// sheet. Any single part that fits the stock at all fits on one such + /// sheet, so placing a part is never scored worse than leaving it out. + public double UnplacedPartPenalty => + CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length); + /// /// Builds the whole-job request this job represents: one NestJobPart per /// requested drawing, and one NestPlateStock per candidate sheet size diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index c6d4d7f..14e58c4 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -100,11 +100,21 @@ namespace OpenNest.Benchmark ); var validation = NestValidator.Validate(plateRuns, requirements); + NestValidator.ValidateAgainstJob( + nestJob, + jobResult, + job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name), + validation + ); 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()); + // Salvage credit is recomputed from the job's own geometry, never taken from the engine. + var netSheetArea = validation.Valid + ? jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p)) + : 0; var sizeBreakdown = plateRuns .GroupBy(pr => pr.Plate.Size.ToString(1)) @@ -157,9 +167,7 @@ namespace OpenNest.Benchmark PlacedArea = placedArea, SalvageRate = salvageRate, MinimumSalvageDimension = minimumSalvageDimension, - EstimatedNetArea = jobResult.Plates.Sum(p => - StockLadderNestingEngine.EstimateNetArea(nestJob, p) - ), + EstimatedNetArea = netSheetArea, Fulfillment = jobResult.Fulfillment, StockUsage = jobResult.StockUsage, Plates = jobResult.Plates, @@ -185,6 +193,8 @@ namespace OpenNest.Benchmark PartsRequested = requested, PlacedArea = placedArea, PlateArea = plateArea, + NetSheetArea = netSheetArea, + UnplacedPartPenalty = job.UnplacedPartPenalty, PlatesUsed = plateRuns.Count, SizeBreakdown = sizeBreakdown, ElapsedMs = sw.ElapsedMilliseconds, @@ -199,6 +209,7 @@ namespace OpenNest.Benchmark JobName = job.Name, Valid = false, PartsRequested = requested, + UnplacedPartPenalty = job.UnplacedPartPenalty, ElapsedMs = sw.ElapsedMilliseconds, Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)", }; @@ -212,6 +223,7 @@ namespace OpenNest.Benchmark JobName = job.Name, Valid = false, PartsRequested = requested, + UnplacedPartPenalty = job.UnplacedPartPenalty, ElapsedMs = sw.ElapsedMilliseconds, Error = $"{ex.GetType().Name}: {ex.Message}", }; diff --git a/OpenNest.Benchmark/DxfManifestLoader.cs b/OpenNest.Benchmark/DxfManifestLoader.cs index 071b6af..302216f 100644 --- a/OpenNest.Benchmark/DxfManifestLoader.cs +++ b/OpenNest.Benchmark/DxfManifestLoader.cs @@ -86,7 +86,7 @@ namespace OpenNest.Benchmark foreach (var text in manifest.SheetSizes ?? new List()) { - if (!Size.TryParse(text, out var size)) + if (!JobLoader.TryParseSheetSize(text, out var size)) throw new InvalidOperationException( $"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")." ); diff --git a/OpenNest.Benchmark/JobLoader.cs b/OpenNest.Benchmark/JobLoader.cs index d30d32e..94ba9dc 100644 --- a/OpenNest.Benchmark/JobLoader.cs +++ b/OpenNest.Benchmark/JobLoader.cs @@ -81,6 +81,29 @@ namespace OpenNest.Benchmark return jobs; } + /// Parses "WxL" with invariant-culture numbers, so "48.5x96" means the + /// same thing on every machine (Size.Parse follows the current culture). + public static bool TryParseSheetSize(string text, out Size size) + { + size = default; + var dims = text?.Split('x', 'X'); + + if (dims == null || dims.Length != 2) + return false; + + var style = System.Globalization.NumberStyles.Float; + var culture = System.Globalization.CultureInfo.InvariantCulture; + + if ( + !double.TryParse(dims[0].Trim(), style, culture, out var width) + || !double.TryParse(dims[1].Trim(), style, culture, out var length) + ) + return false; + + size = new Size(width, length); + return true; + } + private static List ResolveFiles(string inputPath) { if (Directory.Exists(inputPath)) diff --git a/OpenNest.Benchmark/JobResult.cs b/OpenNest.Benchmark/JobResult.cs index 9b09599..85d5f6f 100644 --- a/OpenNest.Benchmark/JobResult.cs +++ b/OpenNest.Benchmark/JobResult.cs @@ -6,8 +6,9 @@ namespace OpenNest.Benchmark /// 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. + /// it. An invalid or crashed run places nothing as far as scoring is + /// concerned: it earns no area and pays the unplaced penalty on every + /// requested part. /// public class JobResult { @@ -20,6 +21,17 @@ namespace OpenNest.Benchmark public int PartsRequested { get; init; } public double PlacedArea { get; init; } public double PlateArea { get; init; } + + /// Sheet area consumed after crediting salvageable offcuts + /// (StockLadderNestingEngine.EstimateNetArea summed over every plate). + /// Equals PlateArea when salvage credit is disabled. + public double NetSheetArea { get; init; } + + /// Sheet area charged for each requested part that was not + /// placed: the largest candidate sheet's area, so leaving a part out + /// always costs at least as much as the extra sheet it would need. + public double UnplacedPartPenalty { get; init; } + public int PlatesUsed { get; init; } public Dictionary SizeBreakdown { get; init; } = new(); public long ElapsedMs { get; init; } @@ -31,5 +43,21 @@ namespace OpenNest.Benchmark /// 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; + + /// Placed area over salvage-credited sheet area. + public double NetUtilization => + Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0; + + public int PartsUnplaced => + Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested; + + /// + /// The ranking score, in sheet area (lower is better): net sheet area + /// consumed plus the unplaced penalty. An engine cannot improve it by + /// dropping awkward parts, and it sums honestly across jobs of + /// different sizes. Invalid runs consume no sheet but pay the penalty + /// on every requested part. + /// + public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty; } } diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index 44d49bd..bb1dea3 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using OpenNest.Converters; +using OpenNest.Engine.Jobs; using OpenNest.Geometry; using OpenNest.Math; @@ -57,6 +58,92 @@ namespace OpenNest.Benchmark return result; } + /// + /// Checks what the materialized layout cannot show: every sheet must be + /// one of the job's own stock entries (an engine may not invent a sheet + /// size or loosen its spacing/edge settings, which the layout checks + /// would otherwise trust), finite stock may not be overdrawn, and every + /// placement's rotation must satisfy its part's RotationPolicy. + /// + public static void ValidateAgainstJob( + NestJob job, + NestJobResult jobResult, + IReadOnlyDictionary displayNames, + ValidationResult result + ) + { + var stockById = job.Plates.ToDictionary(s => s.Id); + var partsById = job.Parts.ToDictionary(p => p.Id); + var sheetsUsed = new Dictionary(); + + foreach (var sheet in jobResult.Plates) + { + if ( + !stockById.TryGetValue(sheet.Stock.Id, out var stock) + || !SameSettings(stock, sheet.Stock) + ) + { + result.Violations.Add( + $"Plate {sheet.PlateIndex} uses stock '{sheet.Stock.Id}' ({sheet.Stock.Size}) that does not match any stock offered by the job" + ); + continue; + } + + sheetsUsed[stock.Id] = sheetsUsed.GetValueOrDefault(stock.Id) + 1; + } + + foreach (var (stockId, used) in sheetsUsed) + { + var available = stockById[stockId].Quantity; + + if (available.HasValue && used > available.Value) + { + result.Violations.Add( + $"Used {used} sheet(s) of stock '{stockId}' but only {available.Value} are available" + ); + } + } + + foreach (var sheet in jobResult.Plates) + { + foreach (var placement in sheet.Placements) + { + if (!partsById.TryGetValue(placement.PartId, out var part)) + continue; // reported by ValidateQuantities + + if (!part.Rotation.Allows(placement.Rotation)) + { + var name = displayNames.TryGetValue(part.Id, out var n) ? n : part.Id; + result.Violations.Add( + $"'{name}' placed at {Angle.ToDegrees(placement.Rotation):F3}° on plate {sheet.PlateIndex}, " + + $"outside its rotation constraint ({Describe(part.Rotation)})" + ); + } + } + } + } + + private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => + ReferenceEquals(expected, actual) + || ( + expected.Size.Equals(actual.Size) + && expected.PartSpacing.IsEqualTo(actual.PartSpacing) + && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) + && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) + && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) + && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) + && expected.Quadrant == actual.Quadrant + ); + + private static string Describe(RotationPolicy policy) => + policy.Kind switch + { + RotationPolicyKind.Fixed => $"fixed at {Angle.ToDegrees(policy.Start):F3}°", + RotationPolicyKind.BoundedSweep => + $"{Angle.ToDegrees(policy.Start):F3}° to {Angle.ToDegrees(policy.End):F3}° in {Angle.ToDegrees(policy.Step):F3}° steps", + _ => "any", + }; + private static void ValidateQuantities( List parts, IReadOnlyDictionary requirements, @@ -142,6 +229,16 @@ namespace OpenNest.Benchmark } } + /// + /// Every pair of parts must be at least apart. + /// Each part's material is inflated by the spacing (perimeter offset + /// outward, holes shrunk inward) and tested against the other part's raw + /// material, with holes subtracted on both sides - so a small part + /// nested inside another part's cutout (part-in-part) is legal as long as + /// it clears the cutout's edge by the spacing. Pairs are pruned with an + /// X-sorted sweep over bounding boxes so only neighbours reach the + /// polygon clipper. + /// private static void ValidateSpacing( List parts, double spacing, @@ -149,29 +246,49 @@ namespace OpenNest.Benchmark ValidationResult result ) { - var worldPolygons = new Polygon[parts.Count]; - var inflatedPolygons = new Polygon[parts.Count]; + var raw = new PartOutline[parts.Count]; + var inflated = new PartOutline[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]; + raw[i] = Outline(parts[i], 0); + inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; } - for (var i = 0; i < parts.Count; i++) - { - if (worldPolygons[i] == null || inflatedPolygons[i] == null) - continue; + var order = Enumerable + .Range(0, parts.Count) + .Where(i => raw[i] != null && inflated[i] != null) + .OrderBy(i => raw[i].Perimeter.BoundingBox.Left) + .ToList(); - for (var j = i + 1; j < parts.Count; j++) + for (var a = 0; a < order.Count; a++) + { + var i = order[a]; + var reach = inflated[i].Perimeter.BoundingBox; + + for (var b = a + 1; b < order.Count; b++) { - if (worldPolygons[j] == null) + var j = order[b]; + var other = raw[j].Perimeter.BoundingBox; + + // Sorted by Left, so nothing further along can reach part i either. + if (other.Left > reach.Right + Tolerance.Epsilon) + break; + + if (!BoxesTouch(reach, other)) continue; - if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j])) + // Inflating one side by the full spacing covers both cases: part j + // inside part i's (shrunk) cutout, or part i's inflated outline + // inside part j's raw cutout. + if ( + Collision.HasOverlap( + inflated[i].Perimeter, + raw[j].Perimeter, + inflated[i].Holes, + raw[j].Holes + ) + ) { result.Violations.Add( $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})" @@ -181,6 +298,12 @@ namespace OpenNest.Benchmark } } + private static bool BoxesTouch(Box a, Box b) => + a.Left <= b.Right + Tolerance.Epsilon + && b.Left <= a.Right + Tolerance.Epsilon + && a.Bottom <= b.Top + Tolerance.Epsilon + && b.Bottom <= a.Top + Tolerance.Epsilon; + /// Friendly name for a violation message, falling back to the materialized /// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all - /// that mismatch is already reported by ValidateQuantities, so this is display-only. @@ -192,12 +315,21 @@ namespace OpenNest.Benchmark ? requirement.Name : part.BaseDrawing.Name; + private sealed class PartOutline + { + public Polygon Perimeter { get; init; } + public List Holes { get; init; } + } + /// - /// 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). + /// Extracts a part's material as world-space polygons - the perimeter and + /// its cutouts - grown by (perimeter offset + /// outward, cutouts offset inward). A cutout that closes up under the + /// offset is dropped, which treats it as solid: conservative, since it + /// has no room for another part at the required spacing anyway. + /// part.Program is already rotated; only a Location offset is needed. /// - private static Polygon WorldPolygon(Part part, double inflateBy) + private static PartOutline Outline(Part part, double inflateBy) { var entities = ConvertProgram .ToGeometry(part.Program) @@ -207,23 +339,61 @@ namespace OpenNest.Benchmark if (entities.Count == 0) return null; - var perimeter = new ShapeProfile(entities).Perimeter; + var profile = new ShapeProfile(entities); - if (perimeter == null) + if (profile.Perimeter == null) return null; + var perimeter = profile.Perimeter; + 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); + var polygon = ToWorldPolygon(perimeter, part.Location); if (polygon == null) return null; - polygon.Offset(part.Location); + var holes = new List(); + + foreach (var cutout in profile.Cutouts) + { + var hole = cutout; + + if (inflateBy > Tolerance.Epsilon) + { + hole = cutout.OffsetInward(inflateBy); + + // An offset that collapsed or flipped inside-out leaves no usable room. + if ( + hole == null + || hole.Area() <= Tolerance.Epsilon + || hole.Area() >= cutout.Area() + ) + continue; + } + + var holePolygon = ToWorldPolygon(hole, part.Location); + + if (holePolygon != null) + holes.Add(holePolygon); + } + + return new PartOutline { Perimeter = polygon, Holes = holes }; + } + + private static Polygon ToWorldPolygon(Shape shape, Vector location) + { + // 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 = shape.ToPolygonWithTolerance(0.01, circumscribe: true); + + if (polygon == null) + return null; + + polygon.Offset(location); + polygon.UpdateBounds(); return polygon; } } diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs index a48266e..8e700fa 100644 --- a/OpenNest.Benchmark/Program.cs +++ b/OpenNest.Benchmark/Program.cs @@ -82,6 +82,18 @@ static class BenchmarkConsole ); } + 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 solves = jobs.Count * engines.Count; @@ -129,7 +141,10 @@ static class BenchmarkConsole break; case "--spacing" when i + 1 < args.Length: - o.PartSpacing = double.Parse(args[++i]); + o.PartSpacing = double.Parse( + args[++i], + System.Globalization.CultureInfo.InvariantCulture + ); break; case "--engines" when i + 1 < args.Length: @@ -195,7 +210,7 @@ static class BenchmarkConsole ) ) { - if (Size.TryParse(token, out var size)) + if (JobLoader.TryParseSheetSize(token, out var size)) sizes.Add(size); else Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping"); @@ -223,16 +238,19 @@ static class BenchmarkConsole "multi-plate/size strategy: how many plates it uses, of which sizes, and how" ); Console.Error.WriteLine( - "demand splits across them. Scoring: aggregate material utilization across every" + "demand splits across them. Ranking: a run that places every requested part beats" ); Console.Error.WriteLine( - "plate used, then (if everything requested was placed) fewer plates as the" + "one that does not; then lower cost = sheet area consumed (minus salvage credit for a" ); Console.Error.WriteLine( - "tie-break. An invalid layout (out of bounds, overlapping, or over-quantity), a" + "usable offcut) + the largest candidate sheet's area per unplaced part; then fewer" ); Console.Error.WriteLine( - "thrown exception, or a run exceeding its time budget all score zero." + "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:"); @@ -261,7 +279,10 @@ static class BenchmarkConsole " --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest" ); Console.Error.WriteLine( - " (default: the distinct sizes already in each file)" + " (default: the distinct sizes already in each file," + ); + Console.Error.WriteLine( + " which hints engines toward the original layout)" ); Console.Error.WriteLine( " --spacing Override part spacing for every job" @@ -273,7 +294,10 @@ static class BenchmarkConsole " --csv Write a flat CSV of all results" ); Console.Error.WriteLine( - " --salvage-rate <0..1> Fraction of eligible offcut area credited (default 0)" + " --salvage-rate <0..1> Fraction of the usable offcut credited back in the" + ); + Console.Error.WriteLine( + " score (default 0; needs --min-salvage-dimension)" ); Console.Error.WriteLine( " --min-salvage-dimension Both offcut dimensions must qualify; 0 disables credit" diff --git a/OpenNest.Benchmark/Report.cs b/OpenNest.Benchmark/Report.cs index 722c099..654bee6 100644 --- a/OpenNest.Benchmark/Report.cs +++ b/OpenNest.Benchmark/Report.cs @@ -9,10 +9,12 @@ namespace OpenNest.Benchmark { /// /// 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. + /// valid beats invalid; placing every requested part beats not; then lower + /// JobResult.Cost wins - salvage-credited sheet area consumed plus a + /// largest-sheet penalty per unplaced part, so dropping awkward parts can + /// never buy a better score; then fewer plates. Ties beyond that are a + /// shared win. Across jobs, costs and areas are summed (not averaged), so + /// a big job weighs more than a three-part one. /// public static class Report { @@ -29,7 +31,7 @@ namespace OpenNest.Benchmark var best = ranked.Count > 0 ? ranked[0] : null; Console.WriteLine( - $"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -8} {"Plates", -18} {"Time(ms)", -9} Notes" + $"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes" ); foreach (var r in ranked) @@ -42,12 +44,13 @@ namespace OpenNest.Benchmark : "INVALID"; var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}"; var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-"; + var netCol = r.Valid ? $"{r.NetUtilization * 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}" + $"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}" ); } } @@ -67,25 +70,33 @@ namespace OpenNest.Benchmark Valid = g.Count(r => r.Valid), Crashed = g.Count(r => r.Crashed), FullyPlaced = g.Count(r => r.FullyPlaced), - TotalUtilization = g.Sum(r => r.Utilization), + Unplaced = g.Sum(r => r.PartsUnplaced), + PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea), + PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea), + NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea), + TotalCost = g.Sum(r => r.Cost), TotalPlates = g.Sum(r => r.PlatesUsed), TotalTimeMs = g.Sum(r => r.ElapsedMs), }) - .OrderByDescending(e => e.TotalUtilization) + .OrderBy(e => e.TotalCost) .ToList(); var wins = CountWins(results); + // Util% and Net% are area-weighted over valid runs (sum placed / sum + // sheet), not a mean of per-job percentages. TotalCost sums across + // jobs, so it is only meaningful when every job uses the same units. Console.WriteLine( - $"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Wins", -6} {"AvgUtil%", -10} {"Plates", -8} {"TotalTime(ms)", -14}" + $"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}" ); foreach (var e in byEngine) { - var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0; + var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0; + var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 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}" + $"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}" ); } } @@ -94,7 +105,7 @@ namespace OpenNest.Benchmark { var sb = new StringBuilder(); sb.AppendLine( - "Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes" + "Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes" ); foreach (var r in results) @@ -111,6 +122,10 @@ namespace OpenNest.Benchmark r.PartsPlaced, r.PartsRequested, r.Utilization.ToString("F4", CultureInfo.InvariantCulture), + r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture), + r.PlateArea.ToString("F2", CultureInfo.InvariantCulture), + r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture), + r.Cost.ToString("F2", CultureInfo.InvariantCulture), r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)), r.ElapsedMs, @@ -159,9 +174,10 @@ namespace OpenNest.Benchmark return wins; } - /// Lower sorts first (better). Valid beats invalid, then higher - /// aggregate utilization, then (if both fully placed) fewer plates used. - private static int Compare(JobResult a, JobResult b) + /// Lower sorts first (better). Valid beats invalid, complete beats + /// incomplete, then lower cost (relative tolerance, since costs are areas + /// in whatever units the job uses), then fewer plates. + public static int Compare(JobResult a, JobResult b) { if (a.Valid != b.Valid) return a.Valid ? -1 : 1; @@ -169,12 +185,16 @@ namespace OpenNest.Benchmark if (!a.Valid) return 0; - var utilDiff = b.Utilization - a.Utilization; + if (a.FullyPlaced != b.FullyPlaced) + return a.FullyPlaced ? -1 : 1; - if (System.Math.Abs(utilDiff) > Epsilon) - return utilDiff > 0 ? 1 : -1; + var costDiff = a.Cost - b.Cost; + var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost)); - if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed) + if (System.Math.Abs(costDiff) > Epsilon * scale) + return costDiff > 0 ? 1 : -1; + + if (a.PlatesUsed != b.PlatesUsed) return a.PlatesUsed > b.PlatesUsed ? 1 : -1; return 0; diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 34080e9..4ef0c39 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -43,7 +43,7 @@ internal static class NestJobPlacementValidator counts.TryGetValue(placement.PartId, out var count); if (count >= available) throw new InvalidOperationException("Candidate overproduces a requirement."); - if (!RotationIsAllowed(part.Rotation, placement.Rotation)) + if (!part.Rotation.Allows(placement.Rotation)) throw new InvalidOperationException( "Candidate rotation is not allowed for the requirement." ); @@ -78,25 +78,6 @@ internal static class NestJobPlacementValidator _ = CreateShape(geometry); } - private static bool RotationIsAllowed(RotationPolicy policy, double rotation) - { - if (policy.Kind == RotationPolicyKind.Automatic) - return true; - if (policy.Kind == RotationPolicyKind.Fixed) - return AnglesEqual(rotation, policy.Start); - if (rotation < policy.Start - Epsilon || rotation > policy.End + Epsilon) - return false; - var steps = (rotation - policy.Start) / policy.Step; - return System.Math.Abs(steps - System.Math.Round(steps)) <= Epsilon; - } - - private static bool AnglesEqual(double left, double right) - { - var delta = (left - right) % (System.Math.PI * 2); - return System.Math.Abs(delta) <= Epsilon - || System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= Epsilon; - } - private static ShapeTopology CreateShape(PartGeometrySnapshot geometry) { var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry)); diff --git a/OpenNest.Engine/Jobs/RotationPolicy.cs b/OpenNest.Engine/Jobs/RotationPolicy.cs index de91872..e714555 100644 --- a/OpenNest.Engine/Jobs/RotationPolicy.cs +++ b/OpenNest.Engine/Jobs/RotationPolicy.cs @@ -44,4 +44,25 @@ public sealed class RotationPolicy double rotationStart, double rotationEnd ) => stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle); + + /// True when a placement rotation (radians) satisfies this policy: anything for + /// Automatic, the fixed angle modulo a full turn, or an exact step inside a bounded sweep. + public bool Allows(double rotation) + { + const double epsilon = 0.0000001; + if (!double.IsFinite(rotation)) + return false; + if (Kind == RotationPolicyKind.Automatic) + return true; + if (Kind == RotationPolicyKind.Fixed) + { + var delta = (rotation - Start) % (System.Math.PI * 2); + return System.Math.Abs(delta) <= epsilon + || System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= epsilon; + } + if (rotation < Start - epsilon || rotation > End + epsilon) + return false; + var steps = (rotation - Start) / Step; + return System.Math.Abs(steps - System.Math.Round(steps)) <= epsilon; + } } diff --git a/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs b/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs new file mode 100644 index 0000000..dc68a0f --- /dev/null +++ b/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs @@ -0,0 +1,296 @@ +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Benchmark; + +public class BenchmarkScoringTests +{ + // ── ranking ────────────────────────────────────────────────────────────── + + [Fact] + public void Compare_CompleteRunBeatsIncompleteRunWithHigherUtilization() + { + var cherryPicked = Result(placed: 150, requested: 219, placedArea: 90, plateArea: 100); + var complete = Result(placed: 219, requested: 219, placedArea: 120, plateArea: 143); + + Assert.True(cherryPicked.Utilization > complete.Utilization); + Assert.True(Report.Compare(complete, cherryPicked) < 0); + } + + [Fact] + public void Compare_AmongIncompleteRuns_PlacingMorePartsWinsEvenOnMoreSheet() + { + var more = Result(placed: 9, requested: 10, placedArea: 90, plateArea: 200); + var fewer = Result(placed: 5, requested: 10, placedArea: 50, plateArea: 100); + + Assert.True(Report.Compare(more, fewer) < 0); + } + + [Fact] + public void Compare_CompleteRuns_SalvageCreditBreaksEqualSheetUsage() + { + var cleanRemnant = Result(10, 10, placedArea: 60, plateArea: 100, netSheetArea: 80); + var scattered = Result(10, 10, placedArea: 60, plateArea: 100, netSheetArea: 100); + + Assert.True(Report.Compare(cleanRemnant, scattered) < 0); + } + + [Fact] + public void Cost_InvalidRunPaysPenaltyOnEveryRequestedPart() + { + var invalid = new JobResult + { + Valid = false, + PartsPlaced = 10, + PartsRequested = 10, + UnplacedPartPenalty = 100, + Violations = { "overlap" }, + }; + + Assert.Equal(1000, invalid.Cost); + } + + // ── validation through the runner ──────────────────────────────────────── + + [Fact] + public void Run_RotationOutsideConstraint_IsInvalid() + { + var part = Rect(10, 5); + LockRotation(part); + var job = Job(0.5, (part, 1)); + + var rotated = RunSingle(job, j => Sheet(j, (j.Parts[0].Id, 20, 20, Angle.HalfPI))); + var upright = RunSingle(job, j => Sheet(j, (j.Parts[0].Id, 20, 20, 0))); + + Assert.False(rotated.Valid); + Assert.Contains(rotated.Violations, v => v.Contains("rotation constraint")); + Assert.True(upright.Valid, string.Join("; ", upright.Violations)); + } + + [Fact] + public void Run_StockWithLoosenedSpacing_IsInvalid() + { + var job = Job(0.5, (Rect(10, 5), 1)); + + var result = RunSingle( + job, + j => + { + var real = j.Plates[0]; + var forged = new NestPlateStock(real.Id, real.Size, null, 0, real.EdgeSpacing, real.Quadrant); + return new[] + { + new NestJobPlateResult(0, forged, new[] { new NestJobPlacement(j.Parts[0].Id, 0, 1, 1, 0) }), + }; + } + ); + + Assert.False(result.Valid); + Assert.Contains(result.Violations, v => v.Contains("does not match any stock")); + } + + [Fact] + public void Run_PartInsideAnotherPartsCutout_IsValidWhenItClearsTheEdge() + { + var job = Job(0.5, (Frame(), 1), (Rect(4, 4), 1)); + + var clear = RunSingle( + job, + j => Sheet(j, (j.Parts[0].Id, 10, 10, 0), (j.Parts[1].Id, 18, 18, 0)) + ); + var tooClose = RunSingle( + job, + j => Sheet(j, (j.Parts[0].Id, 10, 10, 0), (j.Parts[1].Id, 15.2, 18, 0)) + ); + + Assert.True(clear.Valid, string.Join("; ", clear.Violations)); + Assert.True(clear.FullyPlaced); + Assert.False(tooClose.Valid); + Assert.Contains(tooClose.Violations, v => v.Contains("closer than the required spacing")); + } + + [Fact] + public void Run_OverlappingNeighbours_AreStillCaughtBySweep() + { + var job = Job(0.5, (Rect(10, 5), 3)); + + var result = RunSingle( + job, + j => Sheet( + j, + (j.Parts[0].Id, 60, 1, 0), + (j.Parts[0].Id, 1, 1, 0), + (j.Parts[0].Id, 10.2, 1, 0) + ) + ); + + Assert.False(result.Valid); + Assert.Single(result.Violations); + } + + [Fact] + public void Run_WithSalvageCredit_CompactLayoutOutscoresScatteredLayout() + { + var job = Job(0.5, (Rect(10, 5), 2)); + var engines = new List + { + Engine("Compact", j => Sheet(j, (j.Parts[0].Id, 0, 0, 0), (j.Parts[0].Id, 0, 10, 0))), + Engine("Scattered", j => Sheet(j, (j.Parts[0].Id, 0, 0, 0), (j.Parts[0].Id, 86, 43, 0))), + }; + + var results = BenchmarkRunner.Run( + new List { job }, + engines, + salvageRate: 0.5, + minimumSalvageDimension: 10 + ); + var compact = results.Single(r => r.EngineName == "Compact"); + var scattered = results.Single(r => r.EngineName == "Scattered"); + + Assert.True(compact.FullyPlaced && scattered.FullyPlaced); + Assert.Equal(compact.PlateArea, scattered.PlateArea); + Assert.True(compact.NetSheetArea < scattered.NetSheetArea); + Assert.True(Report.Compare(compact, scattered) < 0); + } + + [Fact] + public void TryParseSheetSize_UsesInvariantCulture() + { + var previous = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); + + try + { + Assert.True(JobLoader.TryParseSheetSize("48.5x96", out var size)); + Assert.Equal(48.5, size.Width); + Assert.Equal(96, size.Length); + } + finally + { + Thread.CurrentThread.CurrentCulture = previous; + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private static JobResult Result( + int placed, + int requested, + double placedArea, + double plateArea, + double? netSheetArea = null + ) => + new() + { + Valid = true, + PartsPlaced = placed, + PartsRequested = requested, + PlacedArea = placedArea, + PlateArea = plateArea, + NetSheetArea = netSheetArea ?? plateArea, + UnplacedPartPenalty = 100, + PlatesUsed = 1, + }; + + private static Drawing Rect(double w, double h) + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("rect", pgm); + } + + /// 20x20 square with a 10x10 cutout centred in it. + private static Drawing Frame() + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(5, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5, 15))); + pgm.Codes.Add(new LinearMove(new Vector(15, 15))); + pgm.Codes.Add(new LinearMove(new Vector(15, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5, 5))); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(20, 0))); + pgm.Codes.Add(new LinearMove(new Vector(20, 20))); + pgm.Codes.Add(new LinearMove(new Vector(0, 20))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("frame", pgm); + } + + private static void LockRotation(Drawing drawing) + { + drawing.Constraints ??= new NestConstraints(); + drawing.Constraints.StepAngle = Angle.TwoPI; + drawing.Constraints.StartAngle = 0; + drawing.Constraints.EndAngle = 0; + } + + private static BenchmarkJob Job(double spacing, params (Drawing Drawing, int Quantity)[] parts) => + new() + { + SourceFile = "test.manifest.json", + CandidateSizes = new List { new(48, 96) }, + EdgeSpacing = new Spacing(0, 0), + PartSpacing = spacing, + Quadrant = 1, + Requests = parts + .Select(p => new DrawingRequest { Drawing = p.Drawing, Quantity = p.Quantity }) + .ToList(), + }; + + private static NestJobPlateResult[] Sheet( + NestJob job, + params (string PartId, double X, double Y, double Rotation)[] placements + ) + { + var counts = new Dictionary(); + return new[] + { + new NestJobPlateResult( + 0, + job.Plates[0], + placements.Select(p => + { + var index = counts.GetValueOrDefault(p.PartId); + counts[p.PartId] = index + 1; + return new NestJobPlacement(p.PartId, index, p.X, p.Y, p.Rotation); + }) + ), + }; + } + + private static JobResult RunSingle( + BenchmarkJob job, + Func> layout + ) => Assert.Single(BenchmarkRunner.Run(new List { job }, new[] { Engine("Scripted", layout) })); + + private static NestingEngineInfo Engine( + string name, + Func> layout + ) => new(name, "test double", () => new ScriptedEngine(layout)); + + /// Returns a fixed layout without going through NestJobRunner, the way a + /// hand-written engine could, so only the benchmark's own validator judges it. + private sealed class ScriptedEngine(Func> layout) + : INestingEngine + { + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) => + new( + NestJobStatus.Complete, + NestJobStopReason.Completed, + layout(job), + Array.Empty(), + Array.Empty() + ); + } +} diff --git a/README.md b/README.md index 4d2c645..9972a15 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext ## Benchmarking Nest Engines -`OpenNest.Benchmark` compares every registered `INestingEngine` implementation against each other on a set of `.nest` files, scoring by material utilization. Each engine owns its own multi-plate/size strategy for the whole job — how many plates it uses, of which sizes, and how demand splits across them: +`OpenNest.Benchmark` compares every registered `INestingEngine` implementation against each other on a set of `.nest` files, scoring by the sheet area each one consumes to place the whole job. Each engine owns its own multi-plate/size strategy for the whole job — how many plates it uses, of which sizes, and how demand splits across them: ```bash # Benchmark all registered engines against every .nest file in a folder @@ -242,7 +242,11 @@ DXF paths resolve relative to the manifest. Sheet sizes are required (from the m Each engine-on-job solve is independent, so `--parallel ` (default 3) runs up to `n` at once; `--parallel 1` is strictly sequential. Results and their order in the report are the same either way. The catch is timing: some solves use one core, others spread across many, and when concurrent solves compete for cores `Time(ms)` goes up (a short multi-threaded job showed 2–4× inflation at `--parallel 3`). Scores (utilization, plates, validity) are unaffected, so use `--parallel 1` when comparing speed. The 5-minute per-solve timeout is wall-clock, so contention can also push a slow engine over it. -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. A run that doesn't finish within its time budget also scores zero, as a timeout. +Ranking per job: a valid layout beats an invalid one, placing every requested part beats not, then lower **cost** wins, then fewer plates. Cost is the sheet area consumed, less `--salvage-rate` × the usable offcut on each sheet (the same `EstimateNetArea` estimate `StockLadder` optimizes; recomputed from the job geometry, not taken from the engine), plus the largest candidate sheet's area for every unplaced part. The penalty means leaving awkward parts out can never buy a better score. The summary sums cost and areas across jobs rather than averaging per-job percentages, so a 200-part job outweighs a 3-part one. That only makes sense when every job uses the same units. `Util%` is placed area / sheet area, and `Net%` is placed area / salvage-credited sheet area. + +When no `--sheet-sizes` is given, a `.nest` job only offers the sizes its original layout used, which hints engines toward that answer. The benchmark warns about this; pass the sizes you actually stock for an unbiased comparison. + +An engine's layout is rejected (it places nothing and pays the penalty on every requested part) if any part falls outside the work area, any two parts are closer than the required spacing, a drawing gets more parts placed than requested, a placement breaks its drawing's rotation constraint, or a sheet doesn't match a stock entry the job offered (different size, spacing, edge spacing, or quadrant, or finite stock overdrawn). Part-in-part is allowed: a part may sit inside another part's cutout if it clears the cutout edge by the part spacing. A run that doesn't finish within its time budget also counts as rejected, as a timeout. Custom competitor engines can be added by dropping a DLL implementing `INestingEngine` with a public parameterless constructor into the `Engines/` directory next to the benchmark executable; each one is registered under its own CLR type name. This is a separate plugin contract from the desktop app's `NestEngineRegistry`/`NestEngineBase` (which requires a `(Plate)` constructor) — a `NestEngineBase` plugin dropped into the benchmark's `Engines/` folder is silently skipped, since the benchmark only ever solves whole jobs. @@ -321,7 +325,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 whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. | +| **OpenNest.Benchmark** | Runs every registered whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by salvage-credited sheet area consumed (with a penalty for unplaced parts), so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. | | **OpenNest.Tests** | Cross-platform tests covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, data, and the API. | | **OpenNest.WinForms.Tests** | Windows-only tests for desktop CAD bend-note presentation and cutting-parameter serialization. | @@ -353,7 +357,7 @@ to `0`, which also disables credit. With both enabled, only the largest qualifyi full-span edge rectangle outside placed bounding boxes plus part spacing is credited, within the usable work area; both dimensions must meet the minimum in job units. Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand -valuation is modeled. Benchmark ranking still uses gross material utilization. +valuation is modeled. Benchmark ranking credits the same estimate (see Benchmarking Nest Engines). This is a tested deterministic heuristic baseline, **not an optimal or production- certified solver**. Conservative rectangular free-region hints and linear fills can