diff --git a/OpenNest.Api/NestRequestPlate.cs b/OpenNest.Api/NestRequestPlate.cs
index 6999ad3..31de3e9 100644
--- a/OpenNest.Api/NestRequestPlate.cs
+++ b/OpenNest.Api/NestRequestPlate.cs
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// One explicit physical-stock type for a whole nesting job.
public class NestRequestPlate
{
+ /// Positive per-sheet cost in common units; omit on every row for area scoring.
+ public double? Cost { get; init; }
+
public string Id { get; init; }
public Size Size { get; init; }
diff --git a/OpenNest.Api/NestResponse.cs b/OpenNest.Api/NestResponse.cs
index 6cbd21a..85af3f0 100644
--- a/OpenNest.Api/NestResponse.cs
+++ b/OpenNest.Api/NestResponse.cs
@@ -44,6 +44,7 @@ public class NestResponse
public IReadOnlyList Fulfillment { get; init; } = [];
public IReadOnlyList StockUsage { get; init; } = [];
public IReadOnlyList PlateStockMappings { get; init; } = [];
+ public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
@@ -86,6 +87,7 @@ public class NestResponse
Fulfillment = Fulfillment is null
? []
: new List(Fulfillment),
+ Costs = Costs,
StockUsage = StockUsage is null ? [] : new List(StockUsage),
PlateStockMappings = PlateStockMappings is null
? []
@@ -171,6 +173,7 @@ public class NestResponse
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
+ Costs = archive.Costs,
Nest = nest,
Request = request,
};
@@ -178,6 +181,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto
{
+ public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; }
public int SheetCount { get; init; }
public double Utilization { get; init; }
diff --git a/OpenNest.Api/NestRunner.cs b/OpenNest.Api/NestRunner.cs
index f30e9a5..5e9d265 100644
--- a/OpenNest.Api/NestRunner.cs
+++ b/OpenNest.Api/NestRunner.cs
@@ -28,6 +28,7 @@ public static class NestRunner
if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
+ var stock = CreateStock(request);
var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary(StringComparer.Ordinal);
@@ -82,7 +83,6 @@ public static class NestRunner
});
}
- var stock = CreateStock(request);
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest(
@@ -91,6 +91,9 @@ public static class NestRunner
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
// Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
+ if (request.Plates != null)
+ nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
+ { Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
foreach (var item in items)
nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates)
@@ -144,6 +147,7 @@ public static class NestRunner
Violations = result.Violations,
Fulfillment = fulfillment,
StockUsage = usage,
+ Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest,
@@ -209,11 +213,13 @@ public static class NestRunner
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
- plate.Quadrant
+ plate.Quadrant,
+ plate.Cost
)
);
}
+ NestJobValidator.ValidateStockCosts(stock);
return stock;
}
diff --git a/OpenNest.Tests/Api/NestRunnerTests.cs b/OpenNest.Tests/Api/NestRunnerTests.cs
index 9076b06..881decd 100644
--- a/OpenNest.Tests/Api/NestRunnerTests.cs
+++ b/OpenNest.Tests/Api/NestRunnerTests.cs
@@ -4,14 +4,59 @@ using System.Linq;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Converters;
+using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
-using OpenNest.Engine.Jobs;
namespace OpenNest.Tests.Api;
public class NestRunnerTests
{
+ [Fact]
+ public async Task ExplicitPricesSelectCheaperStockAndSurviveArchive()
+ {
+ var path = CreateTempSquareDxf(4, 4);
+ var archive = Path.ChangeExtension(path, ".nestquote");
+ try
+ {
+ var response = await NestRunner.RunAsync(new NestRequest
+ {
+ Engine = "Rectangles",
+ Parts = [new NestRequestPart { DxfPath = path, Quantity = 1 }],
+ Plates = [new NestRequestPlate { Id = "small", Size = new Size(10, 10), Quantity = 1, Cost = 50 },
+ new NestRequestPlate { Id = "large", Size = new Size(12, 12), Quantity = 1, Cost = 7 }],
+ });
+ Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus);
+ Assert.Equal("large", Assert.Single(response.PlateStockMappings).StockId);
+ Assert.Equal("supplied-cost", response.Costs.Basis);
+ Assert.Equal(7, response.Costs.GrossTotal);
+ Assert.Equal(7, response.Costs.NetScore);
+ Assert.Equal(0, response.Costs.SalvageCredit);
+ await response.SaveAsync(archive);
+ var reopened = await NestResponse.LoadAsync(archive);
+ Assert.Equal(7, reopened.Costs.GrossTotal);
+ Assert.Equal(new[] { 50d, 7d }, reopened.Nest.PlateOptions.Select(p => p.Cost));
+ Assert.Equal(7, reopened.Request.Plates[1].Cost);
+ }
+ finally
+ {
+ File.Delete(path);
+ File.Delete(archive);
+ }
+ }
+
+ [Fact]
+ public async Task MixedCostsFailBeforeImport()
+ {
+ var error = await Assert.ThrowsAsync(() => NestRunner.RunAsync(new NestRequest
+ {
+ Parts = [new NestRequestPart { DxfPath = "missing.dxf", Quantity = 1 }],
+ Plates = [new NestRequestPlate { Id = "priced", Size = new Size(10, 10), Cost = 2 },
+ new NestRequestPlate { Id = "missing", Size = new Size(12, 12) }],
+ }));
+ Assert.Contains("Missing: missing", error.Message);
+ }
+
[Fact]
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{
diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md
index c25db52..dd69713 100644
--- a/docs/automatic-nesting.md
+++ b/docs/automatic-nesting.md
@@ -98,3 +98,20 @@ Response archive schema 3 persists validation status and violations. Older archi
## Verification
`dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj` exercises Console, MCP and API rejection/override, cancellation, physical-sheet settings, response IDs and archive round-trips. Its current target matches MCP's `net8.0-windows` marker but does not use WindowsDesktop and executes on Linux as well as Windows. It is included in the solution and Windows test workflow. Desktop interaction tests remain in Windows-only `OpenNest.WinForms.Tests`.
+
+## Stock costs
+
+API `NestRequestPlate.Cost` is an optional per-physical-sheet cost in caller-consistent
+units. Omit every cost for area scoring, or supply strictly positive finite costs
+for every available row. Explicit zero and mixed priced/unpriced available stock
+are rejected before import. Quantity-zero rows do not select the cost mode.
+
+Validated proposals expose `Costs` (`Basis`, `Stock`, `GrossTotal`, `SalvageCredit`,
+`NetScore`) and persist them in quote archives. `area` identifies fallback scoring;
+`supplied-cost` identifies explicit prices. API salvage is disabled, so two sheets
+costing 25 each report gross and net totals of 50 with zero salvage credit. Totals
+exclude penalties for unplaced demand. Invalid proposals have no cost summary;
+overflow fails instead of becoming a score.
+
+Returned nests preserve offered stock prices. These are cost-aware heuristics,
+not global cost optimizers; validation and fulfillment remain required.
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 483ed31..45c6f71 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -91,3 +91,8 @@ The existing `plateOptions` entries retain width, length and `cost`; no new `.ne
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.
+
+API returned nests retain offered options, excluding rows with zero available
+quantity because `.nest` options do not retain inventory. Options are offers, not
+a per-sheet purchase ledger. API quote archives additionally retain request costs
+and validated proposal cost summaries; old archives load with `Costs == null`.