feat(api): accept complete nesting jobs and report fulfillment

Task 6 of the whole-job engine API: adapt the public NestRequest/NestRunner/
NestResponse surface to delegate to the whole-job runner instead of a manual
quantity loop.

- NestRequest: optional explicit Plates stock list (null keeps the legacy
  unlimited SheetSize fallback; empty list means no available stock),
  optional per-part Id (derived as part-{index} when absent), and an explicit
  PlacementStrategy that takes precedence over the legacy Strategy.
- NestRequestPlate: one physical-stock type (id, size, quantity, spacing,
  quadrant).
- NestRunner: imports each DXF once, propagates priority/rotation constraints,
  runs a single NestJobRunner solve, materializes ID/pose placements exactly
  once, and reports aggregate utilization as total placed part area over total
  physical sheet area.
- NestResponse: exposes status, stop reason, part fulfillment, stock usage,
  and plate-to-stock mapping; .nestquote save/load gains a schema version and
  reports completion as unknown for old archives lacking fulfillment metadata.
- Tests: extend the Api request/runner/persistence suites for legacy SheetSize,
  explicit mixed finite stock, stock exhaustion, weighted utilization, old
  archive loading, and new-archive round trips.

Verification: cross-compiles clean on net8.0-windows (Linux). The Api tests
require a Windows runner (net8.0-windows) and are NOT executed here; the
delegated engine logic is covered by the 70-test net8.0 Engine.Tests suite
(committed in Task 5). Windows runtime verification remains outstanding.
This commit is contained in:
aj
2026-09-18 06:15:04 -04:00
parent 2b0b962c8f
commit ad69023c17
8 changed files with 559 additions and 138 deletions
+79 -26
View File
@@ -1,18 +1,40 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using OpenNest.IO;
namespace OpenNest.Api;
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
/// <summary>Physical-sheet usage for one stock identity.</summary>
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
public class NestResponse
{
public const int CurrentSchemaVersion = 2;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
@@ -20,7 +42,8 @@ public class NestResponse
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
Converters = { new JsonStringEnumConverter() }
};
public async Task SaveAsync(string path)
@@ -28,32 +51,34 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Create);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
// Write request.json
var requestEntry = zip.CreateEntry("request.json");
await using (var stream = requestEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
}
// Write response.json (metrics only)
var metrics = new
{
SheetCount,
Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks
};
// Keep persisted data versioned and detached from the live mutable Nest graph.
var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto
{
SchemaVersion = CurrentSchemaVersion,
SheetCount = SheetCount,
Utilization = Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks,
Status = Status,
StopReason = StopReason,
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment),
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings)
}, JsonOptions);
}
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
var nestEntry = zip.CreateEntry("nest.nest");
using var nestMs = new MemoryStream();
var writer = new NestWriter(Nest);
writer.Write(nestMs);
new NestWriter(Nest).Write(nestMs);
nestMs.Position = 0;
await using (var stream = nestEntry.Open())
{
@@ -66,25 +91,34 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
// Read request.json
var requestEntry = zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request;
await using (var stream = requestEntry.Open())
{
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
}
// Read response.json
var responseEntry = zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
JsonElement metricsJson;
NestResponseArchiveDto archive;
var hasSchemaVersion = false;
var hasStatusMetadata = false;
await using (var stream = responseEntry.Open())
using (var document = await JsonDocument.ParseAsync(stream))
{
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
var root = document.RootElement;
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
hasStatusMetadata = root.TryGetProperty("status", out _) ||
root.TryGetProperty("stopReason", out _) ||
root.TryGetProperty("fulfillment", out _) ||
root.TryGetProperty("stockUsage", out _) ||
root.TryGetProperty("plateStockMappings", out _);
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions)
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
}
// Read embedded nest.nest via NestReader(Stream)
var nestEntry = zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest;
@@ -95,18 +129,37 @@ public class NestResponse
await stream.CopyToAsync(nestMs);
}
nestMs.Position = 0;
var reader = new NestReader(nestMs);
nest = reader.Read();
nest = new NestReader(nestMs).Read();
}
return new NestResponse
{
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
SchemaVersion = hasSchemaVersion ? archive.SchemaVersion : 0,
SheetCount = archive.SheetCount,
Utilization = archive.Utilization,
CutTime = TimeSpan.FromTicks(archive.CutTimeTicks),
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
Status = hasStatusMetadata ? archive.Status : null,
StopReason = hasStatusMetadata ? archive.StopReason : null,
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Nest = nest,
Request = request
};
}
private sealed class NestResponseArchiveDto
{
public int SchemaVersion { get; init; }
public int SheetCount { get; init; }
public double Utilization { get; init; }
public long CutTimeTicks { get; init; }
public long ElapsedTicks { get; init; }
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
public List<NestStockUsage> StockUsage { get; init; } = [];
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
}
}