feat(api): add NestRunner with multi-plate loop
Stateless orchestrator that takes a NestRequest and returns a NestResponse. Imports DXFs, builds NestItems, runs the engine in a multi-plate loop until all parts are placed, computes timing, and returns utilization metrics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
OpenNest.Api/NestRunner.cs
Normal file
130
OpenNest.Api/NestRunner.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
public static class NestRunner
|
||||
{
|
||||
public static Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (request.Parts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
var importer = new DxfImporter();
|
||||
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
||||
var drawing = new Drawing(name);
|
||||
drawing.Program = pgm;
|
||||
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();
|
||||
var remaining = items.Select(item => item.Quantity).ToList();
|
||||
|
||||
while (remaining.Any(q => q > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var plate = new Plate(request.SheetSize)
|
||||
{
|
||||
Thickness = request.Thickness,
|
||||
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
|
||||
{
|
||||
Drawing = items[i].Drawing,
|
||||
Quantity = remaining[i],
|
||||
Priority = items[i].Priority,
|
||||
StepAngle = items[i].StepAngle,
|
||||
});
|
||||
}
|
||||
|
||||
// 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]--;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Compute timing
|
||||
var timingInfo = Timing.GetTimingInfo(nest);
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// 5. Build response
|
||||
var response = new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = nest.Plates.Count > 0
|
||||
? nest.Plates.Average(p => p.Utilization())
|
||||
: 0,
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
78
OpenNest.Tests/Api/NestRunnerTests.cs
Normal file
78
OpenNest.Tests/Api/NestRunnerTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class NestRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_SinglePart_ProducesResponse()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||
|
||||
try
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = dxfPath, Quantity = 4 }],
|
||||
SheetSize = new Size(10, 10),
|
||||
Spacing = 0.1
|
||||
};
|
||||
|
||||
var response = await NestRunner.RunAsync(request);
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.NotNull(response.Nest);
|
||||
Assert.True(response.SheetCount >= 1);
|
||||
Assert.True(response.Utilization > 0);
|
||||
Assert.Equal(request, response.Request);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_BadDxfPath_Throws()
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_EmptyParts_Throws()
|
||||
{
|
||||
var request = new NestRequest { Parts = [] };
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
private static string CreateTempSquareDxf(double width, double height)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, height)));
|
||||
shape.Entities.Add(new Line(new Vector(width, height), new Vector(0, height)));
|
||||
shape.Entities.Add(new Line(new Vector(0, height), new Vector(0, 0)));
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(shape);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
||||
|
||||
var exporter = new DxfExporter();
|
||||
exporter.ExportProgram(pgm, path);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user