feat(engine): score nesting jobs by per-sheet stock cost

This commit is contained in:
aj committed 2026-10-10 12:38:20 -04:00
1 parent 60fb009329
commit 5dbf887b68
19 files changed
+413 -92

No files matched your search

+11 -4
View File
@@ -9,9 +9,9 @@ public class NestJobCostTests
public static IEnumerable<object[]> 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));
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockCostTests
{
public static IEnumerable<object[]> 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<PartFulfillment>(), Array.Empty<StockUsage>());
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<ArgumentOutOfRangeException>(() => Stock("bad", cost));
[Fact]
public void MixedModeRejectsOnlyAvailableStock()
{
Assert.Throws<ArgumentException>(() => 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<OverflowException>(() => 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<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
}
}
+39
View File
@@ -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);
/// <summary>Costs of validated physical sheets, excluding unplaced-demand penalties.</summary>
public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost> 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);
}
}
+1
View File
@@ -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
@@ -4,10 +4,13 @@ using System.Linq;
namespace OpenNest.Engine.Jobs;
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary>
/// <summary>Ranks independent plate trials: priority fulfillment, sheet cost, placement envelope, then input order.</summary>
public sealed class NestJobCandidateComparer
{
private readonly IReadOnlyList<NestJobPart> parts;
private readonly NestJob job;
public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job;
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> 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)
{
+26 -6
View File
@@ -1,10 +1,11 @@
using System;
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Benchmark scoring primitives. Lower costs represent less sheet consumption.</summary>
/// <summary>Generic per-sheet scoring; unspecified costs retain legacy area scores.</summary>
public static class NestJobCost
{
/// <summary>
@@ -66,16 +67,35 @@ public static class NestJobCost
return area - options.SalvageRate * salvage;
}
/// <summary>Largest offered sheet area, charged by the benchmark per unplaced part.</summary>
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);
/// <summary>Largest offered sheet score, charged per unplaced part.</summary>
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));
/// <summary>
/// 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.
/// </summary>
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));
}
+1 -1
View File
@@ -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
+11 -1
View File
@@ -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<NestPlateStock> 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,
+2 -1
View File
@@ -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)
+2
View File
@@ -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(
+9
View File
@@ -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
);
}
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
public Size Size { get; }
+3 -1
View File
@@ -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();
}
@@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs;
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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;
@@ -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));
}
/// <summary>
@@ -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<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
private sealed record Plan(IReadOnlyList<SheetFill> 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;
@@ -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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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)
{
@@ -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);
}
}
+7
View File
@@ -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.
+16
View File
@@ -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.