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:
@@ -6,10 +6,18 @@ namespace OpenNest.Api;
|
|||||||
public class NestRequest
|
public class NestRequest
|
||||||
{
|
{
|
||||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
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);
|
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 string Material { get; init; } = "Steel, A1011 HR";
|
||||||
public double Thickness { get; init; } = 0.06;
|
public double Thickness { get; init; } = 0.06;
|
||||||
public double Spacing { get; init; } = 0.1;
|
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 NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ namespace OpenNest.Api;
|
|||||||
|
|
||||||
public class NestRequestPart
|
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 string DxfPath { get; init; }
|
||||||
public int Quantity { get; init; } = 1;
|
public int Quantity { get; init; } = 1;
|
||||||
public bool AllowRotation { get; init; } = true;
|
public bool AllowRotation { get; init; } = true;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,18 +1,40 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
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 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; }
|
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 double Utilization { get; init; }
|
||||||
public TimeSpan CutTime { get; init; }
|
public TimeSpan CutTime { get; init; }
|
||||||
public TimeSpan Elapsed { 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 Nest Nest { get; init; }
|
||||||
public NestRequest Request { get; init; }
|
public NestRequest Request { get; init; }
|
||||||
|
|
||||||
@@ -20,7 +42,8 @@ public class NestResponse
|
|||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = true,
|
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)
|
public async Task SaveAsync(string path)
|
||||||
@@ -28,32 +51,34 @@ public class NestResponse
|
|||||||
using var fs = new FileStream(path, FileMode.Create);
|
using var fs = new FileStream(path, FileMode.Create);
|
||||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
|
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
|
||||||
|
|
||||||
// Write request.json
|
|
||||||
var requestEntry = zip.CreateEntry("request.json");
|
var requestEntry = zip.CreateEntry("request.json");
|
||||||
await using (var stream = requestEntry.Open())
|
await using (var stream = requestEntry.Open())
|
||||||
{
|
{
|
||||||
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
|
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write response.json (metrics only)
|
// Keep persisted data versioned and detached from the live mutable Nest graph.
|
||||||
var metrics = new
|
|
||||||
{
|
|
||||||
SheetCount,
|
|
||||||
Utilization,
|
|
||||||
CutTimeTicks = CutTime.Ticks,
|
|
||||||
ElapsedTicks = Elapsed.Ticks
|
|
||||||
};
|
|
||||||
var responseEntry = zip.CreateEntry("response.json");
|
var responseEntry = zip.CreateEntry("response.json");
|
||||||
await using (var stream = responseEntry.Open())
|
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");
|
var nestEntry = zip.CreateEntry("nest.nest");
|
||||||
using var nestMs = new MemoryStream();
|
using var nestMs = new MemoryStream();
|
||||||
var writer = new NestWriter(Nest);
|
new NestWriter(Nest).Write(nestMs);
|
||||||
writer.Write(nestMs);
|
|
||||||
nestMs.Position = 0;
|
nestMs.Position = 0;
|
||||||
await using (var stream = nestEntry.Open())
|
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 fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
||||||
|
|
||||||
// Read request.json
|
|
||||||
var requestEntry = zip.GetEntry("request.json")
|
var requestEntry = zip.GetEntry("request.json")
|
||||||
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
||||||
NestRequest request;
|
NestRequest request;
|
||||||
await using (var stream = requestEntry.Open())
|
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")
|
var responseEntry = zip.GetEntry("response.json")
|
||||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
?? 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())
|
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")
|
var nestEntry = zip.GetEntry("nest.nest")
|
||||||
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
||||||
Nest nest;
|
Nest nest;
|
||||||
@@ -95,18 +129,37 @@ public class NestResponse
|
|||||||
await stream.CopyToAsync(nestMs);
|
await stream.CopyToAsync(nestMs);
|
||||||
}
|
}
|
||||||
nestMs.Position = 0;
|
nestMs.Position = 0;
|
||||||
var reader = new NestReader(nestMs);
|
nest = new NestReader(nestMs).Read();
|
||||||
nest = reader.Read();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NestResponse
|
return new NestResponse
|
||||||
{
|
{
|
||||||
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
|
SchemaVersion = hasSchemaVersion ? archive.SchemaVersion : 0,
|
||||||
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
|
SheetCount = archive.SheetCount,
|
||||||
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
|
Utilization = archive.Utilization,
|
||||||
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
|
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,
|
Nest = nest,
|
||||||
Request = request
|
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
@@ -11,125 +11,167 @@ namespace OpenNest.Api;
|
|||||||
|
|
||||||
public static class NestRunner
|
public static class NestRunner
|
||||||
{
|
{
|
||||||
|
private const string LegacyStockId = "legacy-sheet";
|
||||||
|
|
||||||
public static Task<NestResponse> RunAsync(
|
public static Task<NestResponse> RunAsync(
|
||||||
NestRequest request,
|
NestRequest request,
|
||||||
IProgress<NestProgress> progress = null,
|
IProgress<NestProgress> progress = null,
|
||||||
CancellationToken token = default)
|
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));
|
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
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
|
foreach (var part in parts)
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
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,
|
try
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
{
|
{
|
||||||
Drawing = items[i].Drawing,
|
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
|
||||||
Quantity = remaining[i],
|
new CadImportOptions { Quantity = part.Request.Quantity });
|
||||||
Priority = items[i].Priority,
|
}
|
||||||
StepAngle = items[i].StepAngle,
|
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
|
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||||
var engine = NestEngineRegistry.Create(plate);
|
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||||
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]--;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 timingInfo = Timing.GetTimingInfo(nest);
|
||||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
// 5. Build response
|
return Task.FromResult(new NestResponse
|
||||||
var response = new NestResponse
|
|
||||||
{
|
{
|
||||||
SheetCount = nest.Plates.Count,
|
SheetCount = nest.Plates.Count,
|
||||||
Utilization = nest.Plates.Count > 0
|
Utilization = CalculateUtilization(nest),
|
||||||
? nest.Plates.Average(p => p.Utilization())
|
|
||||||
: 0,
|
|
||||||
CutTime = cutTime,
|
CutTime = cutTime,
|
||||||
Elapsed = sw.Elapsed,
|
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,
|
Nest = nest,
|
||||||
Request = request
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class NestRequestTests
|
|||||||
Assert.Empty(request.Parts);
|
Assert.Empty(request.Parts);
|
||||||
Assert.Equal(60, request.SheetSize.Width);
|
Assert.Equal(60, request.SheetSize.Width);
|
||||||
Assert.Equal(120, request.SheetSize.Length);
|
Assert.Equal(120, request.SheetSize.Length);
|
||||||
|
Assert.Null(request.Plates);
|
||||||
|
Assert.Equal("Default", request.PlacementStrategy);
|
||||||
Assert.Equal("Steel, A1011 HR", request.Material);
|
Assert.Equal("Steel, A1011 HR", request.Material);
|
||||||
Assert.Equal(0.06, request.Thickness);
|
Assert.Equal(0.06, request.Thickness);
|
||||||
Assert.Equal(0.1, request.Spacing);
|
Assert.Equal(0.1, request.Spacing);
|
||||||
@@ -38,8 +40,38 @@ public class NestRequestTests
|
|||||||
{
|
{
|
||||||
var part = new NestRequestPart { DxfPath = "part.dxf" };
|
var part = new NestRequestPart { DxfPath = "part.dxf" };
|
||||||
|
|
||||||
|
Assert.Null(part.Id);
|
||||||
Assert.Equal(1, part.Quantity);
|
Assert.Equal(1, part.Quantity);
|
||||||
Assert.True(part.AllowRotation);
|
Assert.True(part.AllowRotation);
|
||||||
Assert.Equal(0, part.Priority);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Api;
|
using OpenNest.Api;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Tests.Api;
|
namespace OpenNest.Tests.Api;
|
||||||
|
|
||||||
public class NestResponsePersistenceTests
|
public class NestResponsePersistenceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SaveAsync_LoadAsync_RoundTrips()
|
public async Task SaveAsync_LoadAsync_RoundTripsCompleteResponseMetadata()
|
||||||
{
|
{
|
||||||
var nest = new Nest("test-nest");
|
var nest = CreateNest("test-part", new Size(60, 120));
|
||||||
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 request = new NestRequest
|
var request = new NestRequest
|
||||||
{
|
{
|
||||||
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }],
|
Parts = [new NestRequestPart { Id = "test-part", DxfPath = "test.dxf", Quantity = 5 }],
|
||||||
SheetSize = new Size(60, 120),
|
Plates = [new NestRequestPlate { Id = "sheet", Size = new Size(60, 120), Quantity = 1, PartSpacing = 0.1 }],
|
||||||
Material = "Steel",
|
Material = "Steel",
|
||||||
Thickness = 0.125,
|
Thickness = 0.125,
|
||||||
Spacing = 0.1
|
Spacing = 0.1
|
||||||
};
|
};
|
||||||
|
|
||||||
var original = new NestResponse
|
var original = new NestResponse
|
||||||
{
|
{
|
||||||
SheetCount = 1,
|
SheetCount = 1,
|
||||||
Utilization = 0.75,
|
Utilization = 0.75,
|
||||||
CutTime = TimeSpan.FromMinutes(12.5),
|
CutTime = TimeSpan.FromMinutes(12.5),
|
||||||
Elapsed = TimeSpan.FromSeconds(3.2),
|
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,
|
Nest = nest,
|
||||||
Request = request
|
Request = request
|
||||||
};
|
};
|
||||||
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
|
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -44,16 +44,24 @@ public class NestResponsePersistenceTests
|
|||||||
await original.SaveAsync(path);
|
await original.SaveAsync(path);
|
||||||
var loaded = await NestResponse.LoadAsync(path);
|
var loaded = await NestResponse.LoadAsync(path);
|
||||||
|
|
||||||
|
Assert.Equal(NestResponse.CurrentSchemaVersion, loaded.SchemaVersion);
|
||||||
Assert.Equal(original.SheetCount, loaded.SheetCount);
|
Assert.Equal(original.SheetCount, loaded.SheetCount);
|
||||||
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
|
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
|
||||||
Assert.Equal(original.CutTime, loaded.CutTime);
|
Assert.Equal(original.CutTime, loaded.CutTime);
|
||||||
Assert.Equal(original.Elapsed, loaded.Elapsed);
|
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.Material, loaded.Request.Material);
|
||||||
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
|
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
|
||||||
Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count);
|
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].DxfPath, loaded.Request.Parts[0].DxfPath);
|
||||||
Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity);
|
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.NotNull(loaded.Nest);
|
||||||
Assert.Single(loaded.Nest.Plates);
|
Assert.Single(loaded.Nest.Plates);
|
||||||
@@ -63,4 +71,111 @@ public class NestResponsePersistenceTests
|
|||||||
File.Delete(path);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Api;
|
using OpenNest.Api;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
@@ -11,7 +12,7 @@ namespace OpenNest.Tests.Api;
|
|||||||
public class NestRunnerTests
|
public class NestRunnerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_SinglePart_ProducesResponse()
|
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
|
||||||
{
|
{
|
||||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||||
|
|
||||||
@@ -26,9 +27,17 @@ public class NestRunnerTests
|
|||||||
|
|
||||||
var response = await NestRunner.RunAsync(request);
|
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.NotNull(response.Nest);
|
||||||
Assert.True(response.SheetCount >= 1);
|
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "part-0");
|
||||||
Assert.True(response.Utilization > 0);
|
Assert.True(response.Utilization > 0);
|
||||||
Assert.Equal(request, response.Request);
|
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<ArgumentException>(() => NestRunner.RunAsync(request));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(dxfPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BadDxfPath_Throws()
|
public async Task RunAsync_BadDxfPath_Throws()
|
||||||
{
|
{
|
||||||
@@ -46,8 +204,7 @@ public class NestRunnerTests
|
|||||||
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
|
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
|
||||||
};
|
};
|
||||||
|
|
||||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
await Assert.ThrowsAsync<FileNotFoundException>(() => NestRunner.RunAsync(request));
|
||||||
() => NestRunner.RunAsync(request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -55,8 +212,7 @@ public class NestRunnerTests
|
|||||||
{
|
{
|
||||||
var request = new NestRequest { Parts = [] };
|
var request = new NestRequest { Parts = [] };
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentException>(
|
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
|
||||||
() => NestRunner.RunAsync(request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateTempSquareDxf(double width, double height)
|
private static string CreateTempSquareDxf(double width, double height)
|
||||||
@@ -69,9 +225,7 @@ public class NestRunnerTests
|
|||||||
|
|
||||||
var pgm = ConvertGeometry.ToProgram(shape);
|
var pgm = ConvertGeometry.ToProgram(shape);
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
||||||
|
|
||||||
Dxf.ExportProgram(pgm, path);
|
Dxf.ExportProgram(pgm, path);
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user