diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index 65d8488..1ad3b84 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -179,7 +179,7 @@ namespace OpenNest.Benchmark var plateArea = plateRuns.Sum(run => run.Plate.Area()); var netSheetArea = validation.Valid ? plateResults.Sum(result => - StockLadderNestingEngine.EstimateNetArea(baselineJob, result) + NestJobCost.NetSheetArea(baselineJob, result) ) : 0; var sizeBreakdown = plateRuns @@ -266,7 +266,7 @@ namespace OpenNest.Benchmark // 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) + NestJobCost.NetSheetArea(nestJob, p) ) : 0; diff --git a/OpenNest.Benchmark/JobResult.cs b/OpenNest.Benchmark/JobResult.cs index 85d5f6f..75287c0 100644 --- a/OpenNest.Benchmark/JobResult.cs +++ b/OpenNest.Benchmark/JobResult.cs @@ -23,7 +23,7 @@ namespace OpenNest.Benchmark public double PlateArea { get; init; } /// Sheet area consumed after crediting salvageable offcuts - /// (StockLadderNestingEngine.EstimateNetArea summed over every plate). + /// (NestJobCost.NetSheetArea summed over every plate). /// Equals PlateArea when salvage credit is disabled. public double NetSheetArea { get; init; } diff --git a/OpenNest.Engine.Tests/Jobs/LegacyNestJobCost.cs b/OpenNest.Engine.Tests/Jobs/LegacyNestJobCost.cs new file mode 100644 index 0000000..0b4a60b --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/LegacyNestJobCost.cs @@ -0,0 +1,42 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; + +namespace OpenNest.Engine.Tests.Jobs; + +// Frozen pre-PR2 computation: keep independent of NestJobCost to detect scoring drift. +internal static class LegacyNestJobCost +{ + public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet) + { + var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length; + var minimum = job.Options.MinimumSalvageDimension; + if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) + return area; + var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea(); + var parts = job.Parts.ToDictionary(p => p.Id); + var boxes = sheet + .Placements.Select(p => + { + var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId])); + part.Rotate(p.Rotation); + part.Location = new OpenNest.Geometry.Vector(p.X, p.Y); + part.UpdateBounds(); + return part.BoundingBox; + }) + .ToList(); + var gap = sheet.Stock.PartSpacing; + var candidates = new[] + { + (work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap), + (work.Length, work.Top - boxes.Max(b => b.Top) - gap), + (boxes.Min(b => b.Left) - work.Left - gap, work.Width), + (work.Right - boxes.Max(b => b.Right) - gap, work.Width), + }; + var salvage = candidates + .Where(c => c.Item1 >= minimum && c.Item2 >= minimum) + .Select(c => c.Item1 * c.Item2) + .DefaultIfEmpty(0) + .Max(); + return area - job.Options.SalvageRate * salvage; + } +} diff --git a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs new file mode 100644 index 0000000..4aedddf --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs @@ -0,0 +1,115 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestJobCostTests +{ + public static IEnumerable EdgeCases() + { + foreach (var rate in new[] { 0.0, 0.5, 1.0 }) + foreach (var edge in new[] { "bottom", "top", "left", "right" }) + foreach (var quadrant in new[] { 1, 2, 3, 4 }) + yield return new object[] { rate, edge, quadrant }; + } + + [Theory] + [MemberData(nameof(EdgeCases))] + public void EveryEdgeMatchesFrozenScoringExactly(double rate, string edge, int quadrant) + { + var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25, + edgeSpacing: new Spacing(1, 1, 1, 1), quadrant: quadrant); + var work = stock.WorkArea; + var width = edge is "left" or "right" ? 4 : 18; + var height = edge is "bottom" or "top" ? 4 : 18; + var x = work.Left + (edge == "left" ? 14 : 0); + var y = work.Bottom + (edge == "bottom" ? 14 : 0); + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram( + TestDrawingFactory.Rectangle(width, height)), 2); + var job = new NestJob(new[] { part }, new[] { stock }, + new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2)); + var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, x, y, 0) }); + var expected = 400 - rate * (18 * 13.75); + + Assert.Equal(expected, LegacyNestJobCost.EstimateNetArea(job, sheet)); + Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(expected, NestJobCost.NetSheetArea(job.Options, stock, new Box(x, y, width, height))); +#pragma warning disable CS0618 // Compatibility API must retain the old result. + Assert.Equal(expected, StockLadderNestingEngine.EstimateNetArea(job, sheet)); +#pragma warning restore CS0618 + } + + [Theory] + [InlineData(0, 5, 100)] + [InlineData(0.5, 0, 100)] + [InlineData(0.5, 7, 100)] + [InlineData(0.5, 6, 70)] + [InlineData(0.5, 5, 70)] + public void StockLadderFixtureRetainsThresholds(double rate, double minimum, double expected) + { + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1); + var stock = new NestPlateStock("sheet", new Size(10, 10)); + var job = new NestJob(new[] { part }, new[] { stock }, + new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum)); + var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }); + + Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet)); + } + + [Theory] + [InlineData(0)] + [InlineData(0.5)] + public void RotatedMarksAndMultipleSheetsKeepExactLegacyCost(double rate) + { + var program = TestDrawingFactory.Rectangle(4, 3); + program.MoveTo(2, 2); + program.Codes.Add(new LinearMove(9, 2) { Layer = LayerType.Scribe }); + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 5); + var stock = new NestPlateStock("sheet", new Size(30, 40)); + var other = new NestPlateStock("large", new Size(50, 50)); + var job = new NestJob(new[] { part }, new[] { stock, other }, + new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2)); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(stock, new[] { ("part", 10.123, 8.456, 0.37), ("part", 25.789, 17.321, 1.12) }); + builder.AddSheet(other, new[] { ("part", 12.345, 19.876, 2.13) }); + var result = builder.Build(NestJobStopReason.NoPlacementFound); + foreach (var sheet in result.Plates) + Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); + Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(job, sheet)) + 5000, + NestJobCost.Evaluate(job, result)); + } + + [Fact] + public void ScoringKeepsEtchBoundsEvenThoughMaterialGeometryExcludesThem() + { + var program = TestDrawingFactory.Rectangle(4, 3); + program.MoveTo(2, 2); + program.Codes.Add(new LinearMove(15, 2) { Layer = LayerType.Scribe }); + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1); + var stock = new NestPlateStock("sheet", new Size(10, 20)); + var job = new NestJob(new[] { part }, new[] { stock }, + new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)); + var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }); + + Assert.Equal(130, LegacyNestJobCost.EstimateNetArea(job, sheet)); + Assert.Equal(130, NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(120, NestJobCost.NetSheetArea(job.Options, stock, JobPartGeometry.Read(part.Geometry).Bounds)); + } + + [Fact] + public void EmptySheetsAndEmptyStockRetainBenchmarkSemantics() + { + var stock = new NestPlateStock("sheet", new Size(10, 10)); + var job = new NestJob(Array.Empty(), new[] { stock }, + new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1)); + var sheet = new NestJobPlateResult(0, stock, Array.Empty()); + Assert.Equal(100, NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet)); + var empty = new NestJob(Array.Empty(), Array.Empty()); + Assert.Equal(0, NestJobCost.UnplacedPartPenalty(empty)); + Assert.Equal(0, NestJobCost.Evaluate(empty, new NestJobResultBuilder(empty).Build(NestJobStopReason.Completed))); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs new file mode 100644 index 0000000..4d883d2 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobCost.cs @@ -0,0 +1,81 @@ +using System.Linq; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +/// Benchmark scoring primitives. Lower costs represent less sheet consumption. +public static class NestJobCost +{ + /// + /// Sheet area less SalvageRate times the largest usable full-width/full-length edge offcut. + /// Both offcut dimensions must meet MinimumSalvageDimension, which must be positive. + /// Bounds intentionally use Part.BoundingBox (including marks) to preserve benchmark scores. + /// + public static double NetSheetArea(NestJob job, NestJobPlateResult sheet) + { + var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length; + var minimum = job.Options.MinimumSalvageDimension; + if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) + return area; + var parts = job.Parts.ToDictionary(p => p.Id); + var boxes = sheet + .Placements.Select(p => + { + var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId])); + part.Rotate(p.Rotation); + part.Location = new OpenNest.Geometry.Vector(p.X, p.Y); + part.UpdateBounds(); + return part.BoundingBox; + }) + .ToList(); + return NetSheetArea(job.Options, sheet.Stock, boxes.Min(b => b.Left), + boxes.Min(b => b.Bottom), boxes.Max(b => b.Right), boxes.Max(b => b.Top)); + } + + /// + /// Computes net area from an existing placed-parts envelope without rebuilding geometry. + /// For benchmark parity the envelope must include the same marks as Part.BoundingBox. + /// Empty sheets should use the sheet overload, which returns their full area. + /// + public static double NetSheetArea(NestJobOptions options, NestPlateStock stock, Box partsEnvelope) => + NetSheetArea(options, stock, partsEnvelope.Left, partsEnvelope.Bottom, + partsEnvelope.Right, partsEnvelope.Top); + + private static double NetSheetArea( + NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top) + { + var area = stock.Size.Width * stock.Size.Length; + var minimum = options.MinimumSalvageDimension; + if (options.SalvageRate == 0 || minimum <= 0) + return area; + var work = stock.WorkArea; + var gap = stock.PartSpacing; + var candidates = new[] + { + (work.Length, bottom - work.Bottom - gap), + (work.Length, work.Top - top - gap), + (left - work.Left - gap, work.Width), + (work.Right - right - gap, work.Width), + }; + var salvage = candidates + .Where(c => c.Item1 >= minimum && c.Item2 >= minimum) + .Select(c => c.Item1 * c.Item2) + .DefaultIfEmpty(0) + .Max(); + return area - options.SalvageRate * salvage; + } + + /// Largest offered sheet area, charged by the benchmark per unplaced part. + public static double UnplacedPartPenalty(NestJob job) => + job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length); + + /// + /// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area. + /// Matches the benchmark for a valid run; this method does not validate the result. + /// + public static double Evaluate(NestJob job, NestJobResult result) => + result.Plates.Sum(sheet => NetSheetArea(job, sheet)) + + System.Math.Max(0, job.Parts.Sum(part => part.Quantity) + - result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job); +} diff --git a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs index feb1a0c..b9f4c8e 100644 --- a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs +++ b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs @@ -97,7 +97,7 @@ public sealed class StockLadderNestingEngine : INestingEngine // Initial construction only: material area, never raw part counts. Repacking below // compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill. var value = - EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]); + NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]); if (value < score - 1e-9) { winner = sheet; @@ -188,7 +188,7 @@ public sealed class StockLadderNestingEngine : INestingEngine var demand = old.SelectMany(s => s.Placements) .GroupBy(p => p.PartId) .ToDictionary(g => g.Key, g => g.Count()); - var baseline = old.Sum(s => EstimateNetArea(job, s)); + var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s)); NestJobPlateResult replacement = null; foreach (var stock in job.Plates) { @@ -215,7 +215,7 @@ public sealed class StockLadderNestingEngine : INestingEngine if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) continue; var trial = new NestJobPlateResult(index, stock, candidate.Placements); - var cost = EstimateNetArea(job, trial); + var cost = NestJobCost.NetSheetArea(job, trial); if (cost >= baseline - 1e-9) continue; baseline = cost; @@ -242,37 +242,7 @@ public sealed class StockLadderNestingEngine : INestingEngine /// empty full-span edge rectangle outside every placed bounding box plus part clearance, within /// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified /// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled. - public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet) - { - var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length; - var minimum = job.Options.MinimumSalvageDimension; - if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) - return area; - var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea(); - var parts = job.Parts.ToDictionary(p => p.Id); - var boxes = sheet - .Placements.Select(p => - { - var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId])); - part.Rotate(p.Rotation); - part.Location = new OpenNest.Geometry.Vector(p.X, p.Y); - part.UpdateBounds(); - return part.BoundingBox; - }) - .ToList(); - var gap = sheet.Stock.PartSpacing; - var candidates = new[] - { - (work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap), - (work.Length, work.Top - boxes.Max(b => b.Top) - gap), - (boxes.Min(b => b.Left) - work.Left - gap, work.Width), - (work.Right - boxes.Max(b => b.Right) - gap, work.Width), - }; - var salvage = candidates - .Where(c => c.Item1 >= minimum && c.Item2 >= minimum) - .Select(c => c.Item1 * c.Item2) - .DefaultIfEmpty(0) - .Max(); - return area - job.Options.SalvageRate * salvage; - } + [Obsolete("Use NestJobCost.NetSheetArea instead.")] + public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet) => + NestJobCost.NetSheetArea(job, sheet); } diff --git a/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs b/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs index dc68a0f..8b498d5 100644 --- a/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs +++ b/OpenNest.Tests/Benchmark/BenchmarkScoringTests.cs @@ -8,6 +8,45 @@ namespace OpenNest.Tests.Benchmark; public class BenchmarkScoringTests { + [Theory] + [InlineData(0, 43, 43, 4080)] + [InlineData(0.5, 43, 43, 4080)] + [InlineData(0, 43, 0, 4080)] + [InlineData(0.5, 43, 0, 4080)] + [InlineData(0, 86, 21, 4104)] + [InlineData(0.5, 86, 21, 4104)] + [InlineData(0, 0, 21, 4104)] + [InlineData(0.5, 0, 21, 4104)] + public void SharedCostEqualsBenchmarkRunForEveryEdgeAndUnplacedParts( + double rate, double x, double y, double salvage) + { + var benchmarkJob = Job(0.5, (Rect(10, 5), 3)); + NestJob? capturedJob = null; + NestJobResult? capturedResult = null; + var engine = Engine("Shared cost parity", job => + { + capturedJob = job; + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, x, y, 0.0) }); + capturedResult = builder.Build(NestJobStopReason.NoPlacementFound); + return capturedResult.Plates; + }); + + var scored = Assert.Single(BenchmarkRunner.Run(new List { benchmarkJob }, new[] { engine }, + salvageRate: rate, minimumSalvageDimension: 10)); + + Assert.True(scored.Valid, string.Join("; ", scored.Violations)); + Assert.NotNull(capturedJob); + Assert.NotNull(capturedResult); + Assert.Equal(2, scored.PartsUnplaced); + Assert.Equal(4608 - rate * salvage, scored.NetSheetArea); + Assert.Equal(scored.NetSheetArea, + capturedResult.Plates.Sum(sheet => NestJobCost.NetSheetArea(capturedJob, sheet))); + Assert.Equal(benchmarkJob.UnplacedPartPenalty, NestJobCost.UnplacedPartPenalty(capturedJob)); + Assert.Equal(scored.NetSheetArea + 2 * 4608, scored.Cost); + Assert.Equal(scored.Cost, NestJobCost.Evaluate(capturedJob, capturedResult)); + } + // ── ranking ────────────────────────────────────────────────────────────── [Fact]