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
+8
View File
@@ -6,10 +6,18 @@ namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
/// <summary>
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
/// an empty list deliberately means no stock is available.
/// </summary>
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
public Size SheetSize { get; init; } = new(60, 120);
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default";
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1;
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}
+2
View File
@@ -2,6 +2,8 @@ namespace OpenNest.Api;
public class NestRequestPart
{
/// <summary>Optional stable requirement identity. NestRunner derives part-{requestIndex} when omitted.</summary>
public string Id { get; init; }
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
+15
View File
@@ -0,0 +1,15 @@
using OpenNest.Geometry;
namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate
{
public string Id { get; init; }
public Size Size { get; init; }
/// <summary>Available physical sheets; null means unlimited.</summary>
public int? Quantity { get; init; }
public double PartSpacing { get; init; }
public Spacing EdgeSpacing { get; init; }
public int Quadrant { get; init; } = 1;
}
+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; } = [];
}
}
+133 -91
View File
@@ -11,125 +11,167 @@ namespace OpenNest.Api;
public static class NestRunner
{
private const string LegacyStockId = "legacy-sheet";
public static Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
if (request.Parts.Count == 0)
ArgumentNullException.ThrowIfNull(request);
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request));
if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
var jobParts = new List<NestJobPart>(parts.Count);
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
foreach (var part in request.Parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(part.DxfPath,
new CadImportOptions { Quantity = part.Quantity });
}
catch (System.Exception ex)
{
throw new InvalidOperationException(
$"Failed to import DXF: {part.DxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
drawings.Add(drawing);
}
// 2. Build NestItems
var items = new List<NestItem>();
for (var i = 0; i < request.Parts.Count; i++)
{
var part = request.Parts[i];
items.Add(new NestItem
{
Drawing = drawings[i],
Quantity = part.Quantity,
Priority = part.Priority,
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
});
}
// 3. Multi-plate loop
var nest = new Nest();
nest.Thickness = request.Thickness;
nest.Material = new Material(request.Material);
var remaining = items.Select(item => item.Quantity).ToList();
while (remaining.Any(q => q > 0))
foreach (var part in parts)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(part.Request.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath);
var plate = new Plate(request.SheetSize)
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
{
PartSpacing = request.Spacing,
};
// Build items for this pass with remaining quantities
var passItems = new List<NestItem>();
for (var i = 0; i < items.Count; i++)
{
if (remaining[i] <= 0) continue;
passItems.Add(new NestItem
try
{
Drawing = items[i].Drawing,
Quantity = remaining[i],
Priority = items[i].Priority,
StepAngle = items[i].StepAngle,
});
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
new CadImportOptions { Quantity = part.Request.Quantity });
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}");
importedByPath.Add(part.Request.DxfPath, drawing);
}
// Run engine
var engine = NestEngineRegistry.Create(plate);
var parts = engine.Nest(passItems, progress, token);
if (parts.Count == 0)
break; // No progress — part doesn't fit on fresh sheet
// Add parts to plate and nest
foreach (var p in parts)
plate.Parts.Add(p);
nest.Plates.Add(plate);
// Deduct placed quantities
foreach (var p in parts)
{
var idx = drawings.IndexOf(p.BaseDrawing);
if (idx >= 0)
remaining[idx]--;
}
ConfigureDrawingForRequirement(drawing, part.Request);
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
}
// 4. Compute timing
var job = new NestJob(jobParts, CreateStock(request),
new NestJobOptions(ResolvePlacementStrategy(request)));
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
// This is the sole translation from immutable result poses to mutable legacy output objects.
var materialized = NestResultMaterializer.Materialize(job, result);
var nest = materialized.Nest;
nest.Thickness = request.Thickness;
nest.Material = new Material(request.Material);
var timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
sw.Stop();
// 5. Build response
var response = new NestResponse
return Task.FromResult(new NestResponse
{
SheetCount = nest.Plates.Count,
Utilization = nest.Plates.Count > 0
? nest.Plates.Average(p => p.Utilization())
: 0,
Utilization = CalculateUtilization(nest),
CutTime = cutTime,
Elapsed = sw.Elapsed,
Status = result.Status,
StopReason = result.StopReason,
Fulfillment = result.Fulfillment
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced))
.ToArray(),
StockUsage = result.StockUsage
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining))
.ToArray(),
PlateStockMappings = result.Plates
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
.ToArray(),
Nest = nest,
Request = request
};
});
}
return Task.FromResult(response);
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts)
{
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < requestParts.Count; index++)
{
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts));
var id = part.Id ?? $"part-{index}";
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
if (!ids.Add(id))
throw new ArgumentException("Part IDs must be unique.", nameof(requestParts));
identified.Add(new IdentifiedRequestPart(id, part));
}
return identified;
}
private static IReadOnlyList<NestPlateStock> CreateStock(NestRequest request)
{
if (request.Plates is null)
{
return
[
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null,
partSpacing: request.Spacing)
];
}
var stock = new List<NestPlateStock>(request.Plates.Count);
foreach (var plate in request.Plates)
{
if (plate is null)
throw new ArgumentException("Request plates must not contain null entries.", nameof(request));
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing,
plate.EdgeSpacing, plate.Quadrant));
}
return stock;
}
private static void ConfigureDrawingForRequirement(Drawing drawing, NestRequestPart part)
{
drawing.Priority = part.Priority;
drawing.Constraints ??= new NestConstraints();
if (!part.AllowRotation)
{
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0;
}
}
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch
{
NestStrategy.Auto => "Default",
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.")
};
private static double CalculateUtilization(Nest nest)
{
var sheetArea = nest.Plates.Sum(plate => plate.Area());
if (sheetArea == 0) return 0;
var placedArea = nest.Plates.Sum(plate => plate.Parts
.Where(part => !part.BaseDrawing.IsCutOff)
.Sum(part => part.BaseDrawing.Area));
return placedArea / sheetArea;
}
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
ArgumentNullException.ThrowIfNull(value);
if (value.LegacyProgress is not null)
progress.Report(value.LegacyProgress);
}
}
}