From 5dbf887b68915852eed724957d4f60499ff8a191 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 12:38:20 -0400 Subject: [PATCH] feat(engine): score nesting jobs by per-sheet stock cost --- .../Jobs/NestJobCostTests.cs | 15 +- OpenNest.Engine.Tests/Jobs/StockCostTests.cs | 165 ++++++++++++++++++ OpenNest.Engine/Jobs/NestCostSummary.cs | 39 +++++ OpenNest.Engine/Jobs/NestJob.cs | 1 + .../Jobs/NestJobCandidateComparer.cs | 12 +- OpenNest.Engine/Jobs/NestJobCost.cs | 32 +++- OpenNest.Engine/Jobs/NestJobRunner.cs | 2 +- OpenNest.Engine/Jobs/NestJobValidator.cs | 12 +- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 3 +- OpenNest.Engine/Jobs/NestPipeline.cs | 2 + OpenNest.Engine/Jobs/NestPlateStock.cs | 9 + OpenNest.Engine/Jobs/NestStockBuilder.cs | 4 +- .../Jobs/StockLadderNestingEngine.cs | 108 ++++++------ .../Default/DefaultNestingEngine.cs | 6 +- .../Irregular/IrregularNestingEngine.cs | 24 +-- .../Rectangles/RectanglesNestingEngine.cs | 16 +- .../StockCostPersistenceTests.cs | 32 ++++ docs/nest-file-format.md | 7 + docs/nesting-engines.md | 16 ++ 19 files changed, 413 insertions(+), 92 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/StockCostTests.cs create mode 100644 OpenNest.Engine/Jobs/NestCostSummary.cs create mode 100644 OpenNest.IO.Tests/StockCostPersistenceTests.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs index 1a5c82c..be145da 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs @@ -9,9 +9,9 @@ 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 }; + 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] @@ -84,7 +84,14 @@ public class NestJobCostTests TestDrawingFactory.Rectangle(4, 3)), 5); var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options); foreach (var sheet in result.Plates) - Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet)); + { + var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet); + Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet)); + var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity, + sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37); + var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements); + Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet)); + } Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000, NestJobCost.Evaluate(job, result)); diff --git a/OpenNest.Engine.Tests/Jobs/StockCostTests.cs b/OpenNest.Engine.Tests/Jobs/StockCostTests.cs new file mode 100644 index 0000000..e64ec79 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/StockCostTests.cs @@ -0,0 +1,165 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class StockCostTests +{ + public static IEnumerable Engines() + { + foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" }) + foreach (var factor in new[] { 1e-12, 1.0, 100.0 }) + yield return new object[] { engine, factor }; + } + + private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) => + new(id, new Size(size, size), quantity, 0.1, default, 1, cost); + + private static NestJob Job(params NestPlateStock[] stocks) => new( + new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks); + + [Fact] + public void ValidationRejectsForgedStockPrice() + { + var job = Job(Stock("offered", 10)); + var forged = Stock("offered", 1); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) }, + Array.Empty(), Array.Empty()); + Assert.NotEmpty(NestLayoutCheck.Violations(job, result)); + } + + [Fact] + public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty() + { + var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0)); + Assert.False(NestJobCost.UsesExplicitCosts(job)); + Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job)); + } + + [Fact] + public void FixedStrategyUsesSalvageOnlyForPricedStock() + { + var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) }; + var candidate = new PlateCandidate(poses); + var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10)); + var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1); + var pricedJob = new NestJob(priced.Parts, priced.Plates, options); + Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1, + candidate, priced.Plates[0], 0) > 0); + var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)), + new NestPlateStock("large", new Size(10, 10)) }, options); + Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0, + candidate, unpriced.Plates[1], 1) > 0); + } + + [Theory] + [InlineData(0, 20, 0)] + [InlineData(0.5, 14, 6)] + public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit) + { + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4)); + var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20); + var result = NestPipeline.Run(new NestPipelineRequest("Rectangles", + new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock }, + new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6))); + Assert.Empty(result.Violations); + var summary = NestCostSummary.FromAccepted(result); + Assert.Equal("supplied-cost", summary.Basis); + Assert.Equal(20, summary.GrossTotal); + Assert.Equal(net, summary.NetScore, 10); + Assert.Equal(credit, summary.SalvageCredit, 10); + Assert.Equal(1, Assert.Single(summary.Stock).Used); + } + + [Theory] + [MemberData(nameof(Engines))] + public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor) + { + foreach (var cheap in new[] { "small", "large" }) + { + var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor), + Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12)); + var result = NestingEngineRegistry.Create(engine).Solve(job); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Empty(NestLayoutCheck.Violations(job, result)); + Assert.Equal(cheap, Assert.Single(result.Plates).StockId); + Assert.Equal(factor, NestJobCost.Evaluate(job, result)); + } + } + + [Theory] + [InlineData("Rectangles")] + [InlineData("Irregular")] + [InlineData("StockLadder")] + [InlineData("Default")] + [InlineData("Fill")] + public void SameSizeStockRetainsIndependentFiniteInventory(string engine) + { + var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) }; + var job = new NestJob(new[] { new NestJobPart("part", + PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks); + var result = NestingEngineRegistry.Create(engine).Solve(job); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Empty(NestLayoutCheck.Violations(job, result)); + Assert.Equal("cheap", result.Plates[0].StockId); + Assert.Single(result.Plates.Where(p => p.StockId == "cheap")); + Assert.Equal(21, NestJobCost.Evaluate(job, result)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void ExplicitCostsMustBeFiniteAndPositive(double cost) => + Assert.Throws(() => Stock("bad", cost)); + + [Fact] + public void MixedModeRejectsOnlyAvailableStock() + { + Assert.Throws(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10)))); + var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0)); + NestJobValidator.Validate(job); + Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job)); + } + + [Theory] + [InlineData(0, 5, 20)] + [InlineData(0.5, 0, 20)] + [InlineData(0.5, 7, 20)] + [InlineData(0.5, 6, 14)] + public void PricesCreditSalvageProportionally(double rate, double minimum, double expected) + { + var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20); + var job = new NestJob(Job(stock).Parts, 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.NetSheetCost(job, sheet), 10); + Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10); + Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10); + } + + [Fact] + public void AggregateOverflowFailsClearly() + { + var stock = Stock("huge", double.MaxValue); + var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock }); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) }); + builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) }); + Assert.Throws(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed))); + } + + [Fact] + public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts() + { + var option = new PlateOption { Width = 10, Length = 10 }; + Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost); + option.Cost = 12; + var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })); + option.Cost = 50; + Assert.Equal(12, stock.Cost); + Assert.Throws(() => NestStockBuilder.FromTemplate(new Plate(10, 10), + new[] { option, new PlateOption { Width = 12, Length = 12 } })); + } +} diff --git a/OpenNest.Engine/Jobs/NestCostSummary.cs b/OpenNest.Engine/Jobs/NestCostSummary.cs new file mode 100644 index 0000000..673add5 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestCostSummary.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +public sealed record NestStockCost(string StockId, int Used, double? Cost, double GrossSubtotal); + +/// Costs of validated physical sheets, excluding unplaced-demand penalties. +public sealed record NestCostSummary(string Basis, IReadOnlyList Stock, + double GrossTotal, double SalvageCredit, double NetScore) +{ + public static NestCostSummary FromAccepted(NestPipelineResult result) + { + if (!result.CanKeep || !result.IsValid) + throw new ArgumentException("Cost reporting requires a validated proposal.", nameof(result)); + var stock = result.Job.Plates.Select(s => + { + var used = result.Plates.Count(p => p.Stock.Id == s.Id); + return new NestStockCost(s.Id, used, s.Cost, + used == 0 ? 0 : NestJobCost.RequireFinite(used * NestJobCost.GrossSheetCost(s))); + }).ToArray(); + var gross = NestJobCost.RequireFinite(stock.Sum(s => s.GrossSubtotal)); + var net = NestJobCost.RequireFinite(result.Plates.Sum(sheet => + { + if (sheet.Parts.Count == 0 || result.Job.Options.SalvageRate == 0 + || result.Job.Options.MinimumSalvageDimension <= 0) + return NestJobCost.GrossSheetCost(sheet.Stock); + var bounds = sheet.Parts.Select(NestLayoutCheck.MaterialBounds).ToArray(); + var left = bounds.Min(b => b.Left); + var bottom = bounds.Min(b => b.Bottom); + return NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock, + new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom)); + })); + return new NestCostSummary(NestJobCost.UsesExplicitCosts(result.Job) + ? "supplied-cost" : "area", stock, gross, NestJobCost.RequireFinite(gross - net), net); + } +} diff --git a/OpenNest.Engine/Jobs/NestJob.cs b/OpenNest.Engine/Jobs/NestJob.cs index 667ad36..0104738 100644 --- a/OpenNest.Engine/Jobs/NestJob.cs +++ b/OpenNest.Engine/Jobs/NestJob.cs @@ -16,6 +16,7 @@ public sealed class NestJob Parts = Own(parts); Plates = Own(plates); Options = options ?? new NestJobOptions(); + NestJobValidator.ValidateStockCosts(Plates); if ( Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count || Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count diff --git a/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs index 353b65e..acd95b2 100644 --- a/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs +++ b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs @@ -4,10 +4,13 @@ using System.Linq; namespace OpenNest.Engine.Jobs; -/// Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order. +/// Ranks independent plate trials: priority fulfillment, sheet cost, placement envelope, then input order. public sealed class NestJobCandidateComparer { private readonly IReadOnlyList parts; + private readonly NestJob job; + + public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job; public NestJobCandidateComparer(IReadOnlyList parts) { @@ -36,7 +39,7 @@ public sealed class NestJobCandidateComparer return leftCount.CompareTo(rightCount); } - var area = Area(rightStock).CompareTo(Area(leftStock)); + var area = Score(right, rightStock).CompareTo(Score(left, leftStock)); if (area != 0) return area; @@ -52,7 +55,10 @@ public sealed class NestJobCandidateComparer parts.First(part => part.Id == placement.PartId).Priority == priority ); - private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length; + private double Score(PlateCandidate candidate, NestPlateStock stock) => + job != null && stock.Cost.HasValue + ? NestJobCost.NetSheetCost(job, new NestJobPlateResult(0, stock, candidate.Placements)) + : NestJobCost.GrossSheetCost(stock); private static double Envelope(PlateCandidate candidate) { diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs index c18fa33..cd7e7ce 100644 --- a/OpenNest.Engine/Jobs/NestJobCost.cs +++ b/OpenNest.Engine/Jobs/NestJobCost.cs @@ -1,10 +1,11 @@ +using System; using System.Linq; using OpenNest.Engine.Jobs.Adapters; using OpenNest.Geometry; namespace OpenNest.Engine.Jobs; -/// Benchmark scoring primitives. Lower costs represent less sheet consumption. +/// Generic per-sheet scoring; unspecified costs retain legacy area scores. public static class NestJobCost { /// @@ -66,16 +67,35 @@ public static class NestJobCost return area - options.SalvageRate * salvage; } - /// Largest offered sheet area, charged by the benchmark per unplaced part. + public static bool UsesExplicitCosts(NestJob job) => + job.Plates.Any(s => s.Quantity != 0 && s.Cost.HasValue); + + public static double RequireFinite(double value) => double.IsFinite(value) ? value + : throw new OverflowException("Stock cost arithmetic exceeded the finite scoring range."); + + public static double GrossSheetCost(NestPlateStock stock) => RequireFinite(stock.Cost ?? stock.Area); + + public static double NetSheetCost(NestJob job, NestJobPlateResult sheet) => + PriceArea(sheet.Stock, NetSheetArea(job, sheet)); + + public static double NetSheetCost(NestJobOptions options, NestPlateStock stock, Box envelope) => + PriceArea(stock, NetSheetArea(options, stock, envelope)); + + private static double PriceArea(NestPlateStock stock, double area) => + RequireFinite(stock.Cost is double cost ? cost * (area / stock.Area) : area); + + /// Largest offered sheet score, charged per unplaced part. public static double UnplacedPartPenalty(NestJob job) => - job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length); + job.Plates.Count == 0 ? 0 : UsesExplicitCosts(job) + ? job.Plates.Where(s => s.Quantity != 0).Max(GrossSheetCost) + : job.Plates.Max(s => RequireFinite(s.Area)); /// - /// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area. + /// Sum of net sheet costs plus unplaced quantity times the largest offered sheet score. /// 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)) + RequireFinite(result.Plates.Sum(sheet => NetSheetCost(job, sheet)) + System.Math.Max(0, job.Parts.Sum(part => part.Quantity) - - result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job); + - result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job)); } diff --git a/OpenNest.Engine/Jobs/NestJobRunner.cs b/OpenNest.Engine/Jobs/NestJobRunner.cs index 0ac155d..6ba05a8 100644 --- a/OpenNest.Engine/Jobs/NestJobRunner.cs +++ b/OpenNest.Engine/Jobs/NestJobRunner.cs @@ -38,7 +38,7 @@ public sealed class NestJobRunner : INestingEngine var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal); var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal); var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal); - var comparer = new NestJobCandidateComparer(job.Parts); + var comparer = new NestJobCandidateComparer(job); var nester = job.Parts.Count == 0 ? null diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs index 98c8c79..94baea1 100644 --- a/OpenNest.Engine/Jobs/NestJobValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -10,11 +10,13 @@ public static class NestJobValidator public static void Validate(NestJob job) { ArgumentNullException.ThrowIfNull(job); + ValidateStockCosts(job.Plates); foreach (var stock in job.Plates) { var edges = stock.EdgeSpacing; if ( - !Positive(stock.Size.Width) + !Positive(stock.Area) + || !Positive(stock.Size.Width) || !Positive(stock.Size.Length) || !Nonnegative(stock.PartSpacing) || !Nonnegative(edges.Left) @@ -61,6 +63,14 @@ public static class NestJobValidator } } + public static void ValidateStockCosts(IEnumerable stocks) + { + var available = stocks.Where(s => s.Quantity != 0).ToArray(); + if (available.Any(s => s.Cost.HasValue) && available.Any(s => !s.Cost.HasValue)) + throw new ArgumentException("Specify a positive cost for every available stock row or omit all costs. Missing: " + + string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id))); + } + internal static void ValidateCandidate( PlateCandidate candidate, NestPlateStock stock, diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index f20e245..c2597a3 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -231,7 +231,8 @@ public static class NestLayoutCheck private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => ReferenceEquals(expected, actual) || ( - expected.Size.Equals(actual.Size) + expected.Cost == actual.Cost + && expected.Size.Equals(actual.Size) && expected.PartSpacing.IsEqualTo(actual.PartSpacing) && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 858a39b..7753c46 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -131,6 +131,8 @@ public static class NestPipeline ); var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep); var validationTime = clock.Elapsed; + if (canKeep && violations.Count == 0) + _ = NestJobCost.Evaluate(job, raw); var plates = canKeep ? raw.Plates.Select(sheet => new ProposedPlate( diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs index d6c7f58..15d8e0b 100644 --- a/OpenNest.Engine/Jobs/NestPlateStock.cs +++ b/OpenNest.Engine/Jobs/NestPlateStock.cs @@ -16,7 +16,14 @@ public sealed class NestPlateStock Spacing edgeSpacing = default, int quadrant = 1 ) + : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { } + + public NestPlateStock(string id, Size size, int? quantity, double partSpacing, + Spacing edgeSpacing, int quadrant, double? cost) { + if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0)) + throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null."); + Cost = cost; ArgumentException.ThrowIfNullOrWhiteSpace(id); if (quantity < 0) throw new ArgumentOutOfRangeException(nameof(quantity)); @@ -36,6 +43,8 @@ public sealed class NestPlateStock ); } + /// Generic per-physical-sheet cost; null selects area scoring. + public double? Cost { get; } public string Id { get; } public Size Size { get; } diff --git a/OpenNest.Engine/Jobs/NestStockBuilder.cs b/OpenNest.Engine/Jobs/NestStockBuilder.cs index b79623b..6f4db77 100644 --- a/OpenNest.Engine/Jobs/NestStockBuilder.cs +++ b/OpenNest.Engine/Jobs/NestStockBuilder.cs @@ -29,9 +29,11 @@ public static class NestStockBuilder quantityPerOption, template.PartSpacing, template.EdgeSpacing, - template.Quadrant + template.Quadrant, + option.Cost == 0 ? null : option.Cost )); } + NestJobValidator.ValidateStockCosts(stock); return stock.AsReadOnly(); } diff --git a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs index b9f4c8e..34c2eac 100644 --- a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs +++ b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs @@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs; /// /// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements -/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional. +/// must reproduce exactly the removed demand and reduce net sheet cost; inventory is transactional. /// This is a deterministic heuristic, not an optimality or geometric impossibility proof. /// public sealed class StockLadderNestingEngine : INestingEngine @@ -41,12 +41,12 @@ public sealed class StockLadderNestingEngine : INestingEngine // Probe actual validated single-part placements, not bounding-box fit assertions. foreach (var part in job.Parts) - foreach (var stock in job.Plates.Where(s => s.Quantity != 0)) - { - var probe = Trial(stock, new[] { WithQuantity(part, 1) }); - if (probe.Placements.Count != 0) - feasible[part.Id].Add(stock.Id); - } + foreach (var stock in job.Plates.Where(s => s.Quantity != 0)) + { + var probe = Trial(stock, new[] { WithQuantity(part, 1) }); + if (probe.Placements.Count != 0) + feasible[part.Id].Add(stock.Id); + } var ordered = job .Parts.OrderBy(p => p.Priority) .ThenBy(p => feasible[p.Id].Count) @@ -97,8 +97,8 @@ 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 = - NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]); - if (value < score - 1e-9) + NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId])); + if (value < score - (stock.Cost.HasValue ? 0 : 1e-9)) { winner = sheet; score = value; @@ -182,55 +182,55 @@ public sealed class StockLadderNestingEngine : INestingEngine var changed = false; // Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree. for (var index = 0; index < sheets.Count; index++) - for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--) - { - var old = sheets.Skip(index).Take(count).ToList(); - var demand = old.SelectMany(s => s.Placements) - .GroupBy(p => p.PartId) - .ToDictionary(g => g.Key, g => g.Count()); - var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s)); - NestJobPlateResult replacement = null; - foreach (var stock in job.Plates) + for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--) { - token.ThrowIfCancellationRequested(); - var returned = old.Count(s => s.StockId == stock.Id); - if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) - continue; - // Even the maximum possible salvage credit cannot beat the incumbent. - var lowerBound = - stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate); - if (lowerBound >= baseline - 1e-9) - continue; - if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) - continue; - var candidate = Trial( - stock, - ordered - .Where(p => demand.ContainsKey(p.Id)) - .Select(p => WithQuantity(p, demand[p.Id])) - ); - var actual = candidate - .Placements.GroupBy(p => p.PartId) + var old = sheets.Skip(index).Take(count).ToList(); + var demand = old.SelectMany(s => s.Placements) + .GroupBy(p => p.PartId) .ToDictionary(g => g.Key, g => g.Count()); - if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) + var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(job, s))); + NestJobPlateResult replacement = null; + foreach (var stock in job.Plates) + { + token.ThrowIfCancellationRequested(); + var returned = old.Count(s => s.StockId == stock.Id); + if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) + continue; + // Even the maximum possible salvage credit cannot beat the incumbent. + var lowerBound = + NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate); + if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 1e-9)) + continue; + if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) + continue; + var candidate = Trial( + stock, + ordered + .Where(p => demand.ContainsKey(p.Id)) + .Select(p => WithQuantity(p, demand[p.Id])) + ); + var actual = candidate + .Placements.GroupBy(p => p.PartId) + .ToDictionary(g => g.Key, g => g.Count()); + 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 = NestJobCost.NetSheetCost(job, trial); + if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9)) + continue; + baseline = cost; + replacement = trial; + } + if (replacement == null) continue; - var trial = new NestJobPlateResult(index, stock, candidate.Placements); - var cost = NestJobCost.NetSheetArea(job, trial); - if (cost >= baseline - 1e-9) - continue; - baseline = cost; - replacement = trial; + // No accounting changes until the entire equivalent-demand candidate is valid. + foreach (var sheet in old) + used[sheet.StockId]--; + used[replacement.StockId]++; + sheets.RemoveRange(index, count); + sheets.Insert(index, replacement); + changed = true; } - if (replacement == null) - continue; - // No accounting changes until the entire equivalent-demand candidate is valid. - foreach (var sheet in old) - used[sheet.StockId]--; - used[replacement.StockId]++; - sheets.RemoveRange(index, count); - sheets.Insert(index, replacement); - changed = true; - } return changed; } } diff --git a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs index bb6ee1f..d978427 100644 --- a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs @@ -100,7 +100,7 @@ public sealed class DefaultNestingEngine : INestingEngine } } - private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order) + private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced) { public static Scored Of(NestJob job, NestJobResult result, int order) { @@ -109,7 +109,7 @@ public sealed class DefaultNestingEngine : INestingEngine var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed); // Cost needs every placement to name a known part, which only a valid result guarantees. var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity; - return new Scored(result, valid, unplaced, cost, order); + return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job)); } public bool IsBetterThan(Scored other) @@ -118,6 +118,8 @@ public sealed class DefaultNestingEngine : INestingEngine return Valid; if (Unplaced != other.Unplaced) return Unplaced < other.Unplaced; + if (Priced && Cost != other.Cost) + return Cost < other.Cost; var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost))); if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) return Cost < other.Cost; diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 12c12ac..2033c19 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -14,7 +14,7 @@ namespace OpenNest.Engine.NestingEngines.Irregular; /// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places /// either the largest part that fills a gap behind the packing front, or the part that advances /// the front least per unit of area covered. Across sheets, every available stock size is -/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the +/// trial-packed and the one with the lowest estimated whole-job cost (its own net cost plus the /// remaining demand at the best efficiency seen) is committed. A handful of deterministic /// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins. /// @@ -120,7 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine var reason = run.Reason; if (unplaced > 0 && reason == NestJobStopReason.Completed) reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. - return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason); + return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job)); } /// @@ -138,7 +138,7 @@ public sealed class IrregularNestingEngine : INestingEngine var prefix = sheets.Take(sheets.Count - k).ToList(); var tail = sheets.Skip(sheets.Count - k).ToList(); var tailParts = tail.Sum(s => s.Parts.Count); - var tailNet = tail.Sum(s => NetArea(job.Options, s)); + var tailNet = tail.Sum(s => NetCost(job.Options, s)); var tailDemand = new int[types.Count]; foreach (var part in tail.SelectMany(s => s.Parts)) tailDemand[part.Orientation.TypeIndex]++; @@ -148,7 +148,7 @@ public sealed class IrregularNestingEngine : INestingEngine int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null; Run? bestRun = null; - var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet); + var bestNet = tailNet - (NestJobCost.UsesExplicitCosts(job) ? 0 : 1e-9 * System.Math.Max(1, tailNet)); foreach (var (axis, beta) in Variants) foreach (var first in job.Plates) { @@ -245,7 +245,7 @@ public sealed class IrregularNestingEngine : INestingEngine var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock)); var fill = packer.Fill(remaining, token); if (fill.Parts.Count > 0) - trials.Add((fill, NetArea(job.Options, fill))); + trials.Add((fill, NetCost(job.Options, fill))); } if (trials.Count == 0) @@ -260,14 +260,14 @@ public sealed class IrregularNestingEngine : INestingEngine var remainingArea = types.Sum(t => remaining[t.Index] * t.Area); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12)); var chosen = trials - .Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)) + .Select((t, order) => (t.Fill, t.Net, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))) .OrderBy(t => t.Estimate) .ThenByDescending(t => t.Fill.Parts.Count) .ThenBy(t => t.order) .First(); sheets.Add(chosen.Fill); - net += chosen.Net; + net = NestJobCost.RequireFinite(net + chosen.Net); used[chosen.Fill.Stock.Id]++; foreach (var part in chosen.Fill.Parts) remaining[part.Orientation.TypeIndex]--; @@ -289,21 +289,23 @@ public sealed class IrregularNestingEngine : INestingEngine return builder.Build(plan.Reason); } - private static double NetArea(NestJobOptions options, SheetFill fill) + private static double NetCost(NestJobOptions options, SheetFill fill) { - if (fill.Parts.Count == 0) return fill.Stock.Area; + if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock); var left = fill.Parts.Min(p => p.Left); var bottom = fill.Parts.Min(p => p.Bottom); - return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, + return NestJobCost.NetSheetCost(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom)); } - private sealed record Plan(IReadOnlyList Sheets, double Cost, int Unplaced, NestJobStopReason Reason) + private sealed record Plan(IReadOnlyList Sheets, double Cost, int Unplaced, NestJobStopReason Reason, bool Priced) { public bool IsBetterThan(Plan other) { if (Unplaced != other.Unplaced) return Unplaced < other.Unplaced; + if (Priced && Cost != other.Cost) + return Cost < other.Cost; var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost)); if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) return Cost < other.Cost; diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs index 510fc44..347266a 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -18,8 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles; /// /// Sheet by sheet, each available stock is packed under several free-space scoring rules and /// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with -/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the -/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates. +/// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the +/// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates. /// Deterministic: no clocks or randomness; the only stop besides completion is the host token. /// public sealed class RectanglesNestingEngine : INestingEngine @@ -78,7 +78,7 @@ public sealed class RectanglesNestingEngine : INestingEngine { var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); if (plan.Parts.Count > 0) - trials.Add((plan, NetArea(job, plan))); + trials.Add((plan, NetCost(job, plan))); } } @@ -101,7 +101,7 @@ public sealed class RectanglesNestingEngine : INestingEngine /// /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the - /// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job + /// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order. /// private static SheetPlan Choose( @@ -111,7 +111,7 @@ public sealed class RectanglesNestingEngine : INestingEngine var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12)); return trials .Select((t, order) => (t.Plan, order, - Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)) + Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio))) .OrderBy(t => PriorityDebt(types, remaining, t.Plan)) .ThenBy(t => t.Estimate) .ThenByDescending(t => t.Plan.MaterialArea) @@ -135,10 +135,10 @@ public sealed class RectanglesNestingEngine : INestingEngine return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; } - private static double NetArea(NestJob job, SheetPlan plan) => + private static double NetCost(NestJob job, SheetPlan plan) => plan.Envelope is { } envelope - ? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope) - : plan.Stock.Area; + ? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope) + : NestJobCost.GrossSheetCost(plan.Stock); private static bool FitsStock(NestPlateStock stock, BoxOrientation o) { diff --git a/OpenNest.IO.Tests/StockCostPersistenceTests.cs b/OpenNest.IO.Tests/StockCostPersistenceTests.cs new file mode 100644 index 0000000..d6992a4 --- /dev/null +++ b/OpenNest.IO.Tests/StockCostPersistenceTests.cs @@ -0,0 +1,32 @@ +using OpenNest.Engine.Jobs; +using OpenNest.IO; + +namespace OpenNest.IO.Tests; + +public class StockCostPersistenceTests +{ + [Theory] + [InlineData(0)] + [InlineData(12.5)] + public void ExistingOptionsRoundTripWithoutChangingMetadata(double cost) + { + var nest = new Nest("priced options") + { + Customer = "customer", + Notes = "notes", + Units = Units.Inches, + SalvageRate = 0.25, + PlateOptions = [new PlateOption { Width = 48, Length = 96, Cost = cost }], + }; + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var reopened = new NestReader(stream).Read(); + Assert.Equal(cost, Assert.Single(reopened.PlateOptions).Cost); + Assert.Equal(nest.Customer, reopened.Customer); + Assert.Equal(nest.Notes, reopened.Notes); + Assert.Equal(nest.SalvageRate, reopened.SalvageRate); + var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(48, 96), reopened.PlateOptions)); + Assert.Equal(cost == 0 ? (double?)null : cost, stock.Cost); + } +} diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index c094fc1..483ed31 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -84,3 +84,10 @@ the existing layers; this change does not alter the strategy or either post. Future hardening: make numeric G-code serialization/parsing culture-invariant as a coordinated compatibility change; do not change just one side. Program content deduplication is optional and must not change per-part ownership. + +## Stock-option prices + +The existing `plateOptions` entries retain width, length and `cost`; no new `.nest` +format version is required. Legacy zero cost means unspecified, not a free sheet. +Positive costs are generic per-sheet values in caller-consistent units. Solving +requires all available options to be priced or all to be unspecified. diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 05ae758..b60abaf 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -120,3 +120,19 @@ executable. A plug-in implements `INestingEngine` with a public parameterless co registers under its CLR type name. A plug-in whose name matches a built-in engine, or a renamed engine's old name, is skipped: a leftover `OpenNest.Engine.Opus55.dll` cannot shadow Irregular. Leftover Gpt6Astra or Qwen38FlashNext DLLs still load as ordinary plug-ins until deleted. + +## Cost basis and heuristic limits + +`NestPlateStock.Cost` is nullable, immutable and per physical sheet. Every available +stock must have a finite positive cost, or all must omit it. Omitted costs preserve +area scoring. The original constructor and area-valued `NetSheetArea` and obsolete +`EstimateNetArea` APIs retain their units. `NetSheetCost` applies proportional salvage +to explicit costs; geometric area still controls fit, utilization and material ordering. + +Rectangles uses net cost in sheet trials and remaining-material estimates. Irregular +uses it in look-ahead, strategy comparison and equivalent-demand tail replacement. +StockLadder ranks net cost per placed material area and uses total net cost for +consolidation. Default compares valid equally fulfilled results by generic job score. +Fixed Fill/Strip/remnant strategies rank priority fulfillment first, then net cost +for priced stock (gross area for legacy unpriced stock), then existing stable ties. +None guarantees globally optimal purchasing or explores every combination of patterns.