From ad69023c1713a0dce76667ab23dbd43eafb015de Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 18 Sep 2026 06:15:04 -0400 Subject: [PATCH] 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. --- OpenNest.Api/NestRequest.cs | 8 + OpenNest.Api/NestRequestPart.cs | 2 + OpenNest.Api/NestRequestPlate.cs | 15 ++ OpenNest.Api/NestResponse.cs | 105 ++++++-- OpenNest.Api/NestRunner.cs | 224 +++++++++++------- OpenNest.Tests/Api/NestRequestTests.cs | 32 +++ .../Api/NestResponsePersistenceTests.cs | 139 ++++++++++- OpenNest.Tests/Api/NestRunnerTests.cs | 172 +++++++++++++- 8 files changed, 559 insertions(+), 138 deletions(-) create mode 100644 OpenNest.Api/NestRequestPlate.cs diff --git a/OpenNest.Api/NestRequest.cs b/OpenNest.Api/NestRequest.cs index 50b7081..3004218 100644 --- a/OpenNest.Api/NestRequest.cs +++ b/OpenNest.Api/NestRequest.cs @@ -6,10 +6,18 @@ namespace OpenNest.Api; public class NestRequest { public IReadOnlyList Parts { get; init; } = []; + /// + /// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback; + /// an empty list deliberately means no stock is available. + /// + public IReadOnlyList Plates { get; init; } public Size SheetSize { get; init; } = new(60, 120); + /// Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy. + 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; + /// Legacy compatibility setting; Auto maps to the Default whole-job strategy. public NestStrategy Strategy { get; init; } = NestStrategy.Auto; public CutParameters Cutting { get; init; } = CutParameters.Default; } diff --git a/OpenNest.Api/NestRequestPart.cs b/OpenNest.Api/NestRequestPart.cs index f0ea6b5..9b3775b 100644 --- a/OpenNest.Api/NestRequestPart.cs +++ b/OpenNest.Api/NestRequestPart.cs @@ -2,6 +2,8 @@ namespace OpenNest.Api; public class NestRequestPart { + /// Optional stable requirement identity. NestRunner derives part-{requestIndex} when omitted. + public string Id { get; init; } public string DxfPath { get; init; } public int Quantity { get; init; } = 1; public bool AllowRotation { get; init; } = true; diff --git a/OpenNest.Api/NestRequestPlate.cs b/OpenNest.Api/NestRequestPlate.cs new file mode 100644 index 0000000..da24995 --- /dev/null +++ b/OpenNest.Api/NestRequestPlate.cs @@ -0,0 +1,15 @@ +using OpenNest.Geometry; + +namespace OpenNest.Api; + +/// One explicit physical-stock type for a whole nesting job. +public class NestRequestPlate +{ + public string Id { get; init; } + public Size Size { get; init; } + /// Available physical sheets; null means unlimited. + public int? Quantity { get; init; } + public double PartSpacing { get; init; } + public Spacing EdgeSpacing { get; init; } + public int Quadrant { get; init; } = 1; +} diff --git a/OpenNest.Api/NestResponse.cs b/OpenNest.Api/NestResponse.cs index 6b8df0c..2aa829c 100644 --- a/OpenNest.Api/NestResponse.cs +++ b/OpenNest.Api/NestResponse.cs @@ -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; +/// Stable fulfillment metadata for one requested part identity. +public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced); + +/// Physical-sheet usage for one stock identity. +public sealed record NestStockUsage(string StockId, int Used, int? Remaining); + +/// Maps each materialized physical sheet to its source stock identity. +public sealed record NestPlateStockMapping(int PlateIndex, string StockId); + public class NestResponse { + public const int CurrentSchemaVersion = 2; + + /// Zero identifies an archive written before response metadata was versioned. + public int SchemaVersion { get; init; } = CurrentSchemaVersion; public int SheetCount { get; init; } + /// Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio. public double Utilization { get; init; } public TimeSpan CutTime { get; init; } public TimeSpan Elapsed { get; init; } + /// Null means an older archive did not record whole-job fulfillment status. + public NestJobStatus? Status { get; init; } + public NestJobStopReason? StopReason { get; init; } + public IReadOnlyList Fulfillment { get; init; } = []; + public IReadOnlyList StockUsage { get; init; } = []; + public IReadOnlyList 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(Fulfillment), + StockUsage = StockUsage is null ? [] : new List(StockUsage), + PlateStockMappings = PlateStockMappings is null ? [] : new List(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(stream, JsonOptions); + request = await JsonSerializer.DeserializeAsync(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(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(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 Fulfillment { get; init; } = []; + public List StockUsage { get; init; } = []; + public List PlateStockMappings { get; init; } = []; + } } diff --git a/OpenNest.Api/NestRunner.cs b/OpenNest.Api/NestRunner.cs index 847a4af..4ad7eb7 100644 --- a/OpenNest.Api/NestRunner.cs +++ b/OpenNest.Api/NestRunner.cs @@ -11,125 +11,167 @@ namespace OpenNest.Api; public static class NestRunner { + private const string LegacyStockId = "legacy-sheet"; + public static Task RunAsync( NestRequest request, IProgress 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(StringComparer.Ordinal); + var jobParts = new List(parts.Count); - // 1. Import DXFs → Drawings - var drawings = new List(); - 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(); - 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(); - 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 IdentifyParts(IReadOnlyList requestParts) + { + var identified = new List(requestParts.Count); + var ids = new HashSet(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 CreateStock(NestRequest request) + { + if (request.Plates is null) + { + return + [ + new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null, + partSpacing: request.Spacing) + ]; + } + + var stock = new List(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 progress) : IProgress + { + public void Report(NestJobProgress value) + { + ArgumentNullException.ThrowIfNull(value); + if (value.LegacyProgress is not null) + progress.Report(value.LegacyProgress); + } } } diff --git a/OpenNest.Tests/Api/NestRequestTests.cs b/OpenNest.Tests/Api/NestRequestTests.cs index 77e0b3b..fb1a49a 100644 --- a/OpenNest.Tests/Api/NestRequestTests.cs +++ b/OpenNest.Tests/Api/NestRequestTests.cs @@ -13,6 +13,8 @@ public class NestRequestTests Assert.Empty(request.Parts); Assert.Equal(60, request.SheetSize.Width); Assert.Equal(120, request.SheetSize.Length); + Assert.Null(request.Plates); + Assert.Equal("Default", request.PlacementStrategy); Assert.Equal("Steel, A1011 HR", request.Material); Assert.Equal(0.06, request.Thickness); Assert.Equal(0.1, request.Spacing); @@ -38,8 +40,38 @@ public class NestRequestTests { var part = new NestRequestPart { DxfPath = "part.dxf" }; + Assert.Null(part.Id); Assert.Equal(1, part.Quantity); Assert.True(part.AllowRotation); Assert.Equal(0, part.Priority); } + + [Fact] + public void ExplicitPlates_PreserveStockSettings() + { + var request = new NestRequest + { + Plates = + [ + new NestRequestPlate + { + Id = "remnant", + Size = new Size(24, 48), + Quantity = 3, + PartSpacing = 0.2, + EdgeSpacing = new Spacing(1, 2, 3, 4), + Quadrant = 3 + } + ] + }; + + var plate = Assert.Single(request.Plates!); + Assert.Equal("remnant", plate.Id); + Assert.Equal(24, plate.Size.Width); + Assert.Equal(48, plate.Size.Length); + Assert.Equal(3, plate.Quantity); + Assert.Equal(0.2, plate.PartSpacing); + Assert.Equal(new Spacing(1, 2, 3, 4), plate.EdgeSpacing); + Assert.Equal(3, plate.Quadrant); + } } diff --git a/OpenNest.Tests/Api/NestResponsePersistenceTests.cs b/OpenNest.Tests/Api/NestResponsePersistenceTests.cs index 67aa709..2f3a236 100644 --- a/OpenNest.Tests/Api/NestResponsePersistenceTests.cs +++ b/OpenNest.Tests/Api/NestResponsePersistenceTests.cs @@ -1,42 +1,42 @@ using System; using System.IO; +using System.IO.Compression; +using System.Text.Json; using System.Threading.Tasks; using OpenNest.Api; using OpenNest.Geometry; +using OpenNest.IO; namespace OpenNest.Tests.Api; public class NestResponsePersistenceTests { [Fact] - public async Task SaveAsync_LoadAsync_RoundTrips() + public async Task SaveAsync_LoadAsync_RoundTripsCompleteResponseMetadata() { - var nest = new Nest("test-nest"); - var plate = new Plate(new Size(60, 120)); - var drawing = new Drawing("test-part"); - nest.Drawings.Add(drawing); - plate.Parts.Add(new Part(drawing)); - nest.Plates.Add(plate); - + var nest = CreateNest("test-part", new Size(60, 120)); var request = new NestRequest { - Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }], - SheetSize = new Size(60, 120), + Parts = [new NestRequestPart { Id = "test-part", DxfPath = "test.dxf", Quantity = 5 }], + Plates = [new NestRequestPlate { Id = "sheet", Size = new Size(60, 120), Quantity = 1, PartSpacing = 0.1 }], Material = "Steel", Thickness = 0.125, Spacing = 0.1 }; - var original = new NestResponse { SheetCount = 1, Utilization = 0.75, CutTime = TimeSpan.FromMinutes(12.5), Elapsed = TimeSpan.FromSeconds(3.2), + Status = NestJobStatus.Complete, + StopReason = NestJobStopReason.Completed, + Fulfillment = [new NestPartFulfillment("test-part", 5, 5, 0)], + StockUsage = [new NestStockUsage("sheet", 1, 0)], + PlateStockMappings = [new NestPlateStockMapping(0, "sheet")], Nest = nest, Request = request }; - var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote"); try @@ -44,16 +44,24 @@ public class NestResponsePersistenceTests await original.SaveAsync(path); var loaded = await NestResponse.LoadAsync(path); + Assert.Equal(NestResponse.CurrentSchemaVersion, loaded.SchemaVersion); Assert.Equal(original.SheetCount, loaded.SheetCount); Assert.Equal(original.Utilization, loaded.Utilization, precision: 4); Assert.Equal(original.CutTime, loaded.CutTime); Assert.Equal(original.Elapsed, loaded.Elapsed); + Assert.Equal(NestJobStatus.Complete, loaded.Status); + Assert.Equal(NestJobStopReason.Completed, loaded.StopReason); + Assert.Equal(original.Fulfillment, loaded.Fulfillment); + Assert.Equal(original.StockUsage, loaded.StockUsage); + Assert.Equal(original.PlateStockMappings, loaded.PlateStockMappings); Assert.Equal(original.Request.Material, loaded.Request.Material); Assert.Equal(original.Request.Thickness, loaded.Request.Thickness); Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count); + Assert.Equal("test-part", loaded.Request.Parts[0].Id); Assert.Equal(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath); Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity); + Assert.Equal("sheet", Assert.Single(loaded.Request.Plates!).Id); Assert.NotNull(loaded.Nest); Assert.Single(loaded.Nest.Plates); @@ -63,4 +71,111 @@ public class NestResponsePersistenceTests File.Delete(path); } } + + [Fact] + public async Task LoadAsync_LegacyArchiveWithoutFulfillment_LeavesStatusUnspecified() + { + var path = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid()}.nestquote"); + + try + { + await WriteLegacyArchiveAsync(path, CreateNest("legacy-part", new Size(60, 120))); + + var loaded = await NestResponse.LoadAsync(path); + + Assert.Equal(0, loaded.SchemaVersion); + Assert.Null(loaded.Status); + Assert.Null(loaded.StopReason); + Assert.Empty(loaded.Fulfillment); + Assert.Empty(loaded.StockUsage); + Assert.Empty(loaded.PlateStockMappings); + Assert.Equal(1, loaded.SheetCount); + Assert.Equal(0.75, loaded.Utilization, precision: 4); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task SaveAsync_LoadAsync_IncompleteResponsePreservesIdsAndUnplacedWithoutDxf() + { + var dxfPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.dxf"); + var path = Path.Combine(Path.GetTempPath(), $"incomplete-{Guid.NewGuid()}.nestquote"); + Assert.False(File.Exists(dxfPath)); + var original = new NestResponse + { + SheetCount = 1, + Utilization = 0.4, + CutTime = TimeSpan.FromMinutes(2), + Elapsed = TimeSpan.FromMilliseconds(500), + Status = NestJobStatus.Incomplete, + StopReason = NestJobStopReason.StockExhausted, + Fulfillment = [new NestPartFulfillment("custom-id", 3, 1, 2)], + StockUsage = [new NestStockUsage("finite-stock", 1, 0)], + PlateStockMappings = [new NestPlateStockMapping(0, "finite-stock")], + Nest = CreateNest("custom-id", new Size(10, 10)), + Request = new NestRequest + { + Parts = [new NestRequestPart { Id = "custom-id", DxfPath = dxfPath, Quantity = 3 }], + Plates = [new NestRequestPlate { Id = "finite-stock", Size = new Size(10, 10), Quantity = 1 }] + } + }; + + try + { + await original.SaveAsync(path); + var loaded = await NestResponse.LoadAsync(path); + + Assert.False(File.Exists(dxfPath)); + Assert.Equal(NestJobStatus.Incomplete, loaded.Status); + Assert.Equal(NestJobStopReason.StockExhausted, loaded.StopReason); + Assert.Equal(new NestPartFulfillment("custom-id", 3, 1, 2), Assert.Single(loaded.Fulfillment)); + Assert.Equal(new NestStockUsage("finite-stock", 1, 0), Assert.Single(loaded.StockUsage)); + Assert.Equal(new NestPlateStockMapping(0, "finite-stock"), Assert.Single(loaded.PlateStockMappings)); + Assert.Equal("custom-id", Assert.Single(loaded.Request.Parts).Id); + Assert.Equal("finite-stock", Assert.Single(loaded.Request.Plates!).Id); + Assert.Single(loaded.Nest.Drawings); + } + finally + { + File.Delete(path); + } + } + + private static Nest CreateNest(string drawingName, Size size) + { + var nest = new Nest("test-nest"); + var plate = new Plate(size); + var drawing = new Drawing(drawingName); + nest.Drawings.Add(drawing); + plate.Parts.Add(new Part(drawing)); + nest.Plates.Add(plate); + return nest; + } + + private static async Task WriteLegacyArchiveAsync(string path, Nest nest) + { + using var fs = new FileStream(path, FileMode.Create); + using var zip = new ZipArchive(fs, ZipArchiveMode.Create); + await WriteEntryAsync(zip, "request.json", """ + {"parts":[{"dxfPath":"legacy-missing.dxf","quantity":2}],"sheetSize":{"width":60,"length":120},"material":"Steel","thickness":0.06,"spacing":0.1,"strategy":0} + """); + await WriteEntryAsync(zip, "response.json", """ + {"sheetCount":1,"utilization":0.75,"cutTimeTicks":120000,"elapsedTicks":340000} + """); + + var nestEntry = zip.CreateEntry("nest.nest"); + await using var stream = nestEntry.Open(); + new NestWriter(nest).Write(stream); + } + + private static async Task WriteEntryAsync(ZipArchive zip, string name, string contents) + { + var entry = zip.CreateEntry(name); + await using var stream = entry.Open(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(contents); + } } diff --git a/OpenNest.Tests/Api/NestRunnerTests.cs b/OpenNest.Tests/Api/NestRunnerTests.cs index 50c5e5f..21ca635 100644 --- a/OpenNest.Tests/Api/NestRunnerTests.cs +++ b/OpenNest.Tests/Api/NestRunnerTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using OpenNest.Api; using OpenNest.Converters; @@ -11,7 +12,7 @@ namespace OpenNest.Tests.Api; public class NestRunnerTests { [Fact] - public async Task RunAsync_SinglePart_ProducesResponse() + public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId() { var dxfPath = CreateTempSquareDxf(2, 2); @@ -26,9 +27,17 @@ public class NestRunnerTests var response = await NestRunner.RunAsync(request); - Assert.NotNull(response); + Assert.Equal(NestJobStatus.Complete, response.Status); + Assert.Equal(NestJobStopReason.Completed, response.StopReason); + Assert.Equal("part-0", Assert.Single(response.Fulfillment).PartId); + Assert.Equal(4, response.Fulfillment[0].Placed); + var stock = Assert.Single(response.StockUsage); + Assert.Equal("legacy-sheet", stock.StockId); + Assert.Null(stock.Remaining); + Assert.All(response.PlateStockMappings, mapping => Assert.Equal("legacy-sheet", mapping.StockId)); + Assert.Equal(response.SheetCount, response.PlateStockMappings.Count); Assert.NotNull(response.Nest); - Assert.True(response.SheetCount >= 1); + Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "part-0"); Assert.True(response.Utilization > 0); Assert.Equal(request, response.Request); } @@ -38,6 +47,155 @@ public class NestRunnerTests } } + [Fact] + public async Task RunAsync_ExplicitMixedFinitePlates_UsesPhysicalStockEntries() + { + var dxfPath = CreateTempSquareDxf(4, 4); + + try + { + var response = await NestRunner.RunAsync(new NestRequest + { + Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }], + Plates = + [ + new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 }, + new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 } + ] + }); + + Assert.Equal(NestJobStatus.Complete, response.Status); + Assert.Equal(2, response.SheetCount); + Assert.Equal(5, Assert.Single(response.Fulfillment).Placed); + Assert.Equal(0, response.Fulfillment[0].Unplaced); + Assert.Equal(new[] { "large", "small" }, response.PlateStockMappings.Select(mapping => mapping.StockId).Order()); + Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "small").Used); + Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "large").Used); + Assert.All(response.StockUsage, usage => Assert.Equal(0, usage.Remaining)); + } + finally + { + File.Delete(dxfPath); + } + } + + [Fact] + public async Task RunAsync_ExplicitEmptyPlates_ReportsStockExhausted() + { + var dxfPath = CreateTempSquareDxf(2, 2); + + try + { + var response = await NestRunner.RunAsync(new NestRequest + { + Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 1 }], + Plates = [] + }); + + Assert.Equal(NestJobStatus.Incomplete, response.Status); + Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason); + Assert.Equal(0, response.SheetCount); + Assert.Empty(response.StockUsage); + var fulfillment = Assert.Single(response.Fulfillment); + Assert.Equal(0, fulfillment.Placed); + Assert.Equal(1, fulfillment.Unplaced); + Assert.Empty(response.Nest.Plates); + Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "square"); + } + finally + { + File.Delete(dxfPath); + } + } + + [Fact] + public async Task RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation() + { + var dxfPath = CreateTempSquareDxf(4, 4); + + try + { + var response = await NestRunner.RunAsync(new NestRequest + { + Parts = [new NestRequestPart + { + Id = "locked-square", + DxfPath = dxfPath, + Quantity = 2, + AllowRotation = false + }], + Plates = [new NestRequestPlate { Id = "only-sheet", Size = new Size(5, 5), Quantity = 1 }] + }); + + Assert.Equal(NestJobStatus.Incomplete, response.Status); + Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason); + var fulfillment = Assert.Single(response.Fulfillment); + Assert.Equal(2, fulfillment.Requested); + Assert.Equal(1, fulfillment.Placed); + Assert.Equal(1, fulfillment.Unplaced); + var stock = Assert.Single(response.StockUsage); + Assert.Equal(1, stock.Used); + Assert.Equal(0, stock.Remaining); + var drawing = Assert.Single(response.Nest.Drawings); + Assert.Equal("locked-square", drawing.Name); + Assert.Equal(OpenNest.Math.Angle.TwoPI, drawing.Constraints.StepAngle); + } + finally + { + File.Delete(dxfPath); + } + } + + [Fact] + public async Task RunAsync_MixedPhysicalSheets_CalculatesWeightedUtilization() + { + var dxfPath = CreateTempSquareDxf(4, 4); + + try + { + var response = await NestRunner.RunAsync(new NestRequest + { + Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }], + Plates = + [ + new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 }, + new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 } + ] + }); + + Assert.Equal(2, response.SheetCount); + Assert.Equal(80d / 106d, response.Utilization, precision: 6); + } + finally + { + File.Delete(dxfPath); + } + } + + [Fact] + public async Task RunAsync_DuplicatePartIds_ThrowsBeforeNesting() + { + var dxfPath = CreateTempSquareDxf(2, 2); + + try + { + var request = new NestRequest + { + Parts = + [ + new NestRequestPart { Id = "duplicate", DxfPath = dxfPath }, + new NestRequestPart { Id = "duplicate", DxfPath = dxfPath } + ] + }; + + await Assert.ThrowsAsync(() => NestRunner.RunAsync(request)); + } + finally + { + File.Delete(dxfPath); + } + } + [Fact] public async Task RunAsync_BadDxfPath_Throws() { @@ -46,8 +204,7 @@ public class NestRunnerTests Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }] }; - await Assert.ThrowsAsync( - () => NestRunner.RunAsync(request)); + await Assert.ThrowsAsync(() => NestRunner.RunAsync(request)); } [Fact] @@ -55,8 +212,7 @@ public class NestRunnerTests { var request = new NestRequest { Parts = [] }; - await Assert.ThrowsAsync( - () => NestRunner.RunAsync(request)); + await Assert.ThrowsAsync(() => NestRunner.RunAsync(request)); } private static string CreateTempSquareDxf(double width, double height) @@ -69,9 +225,7 @@ public class NestRunnerTests var pgm = ConvertGeometry.ToProgram(shape); var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf"); - Dxf.ExportProgram(pgm, path); - return path; } }