From f13443b6b377610b77c48203256c517d23d260b9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Mar 2026 08:37:44 -0400 Subject: [PATCH] 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) --- OpenNest.Api/NestRunner.cs | 130 ++++++++++++++++++++++++++ OpenNest.Tests/Api/NestRunnerTests.cs | 78 ++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 OpenNest.Api/NestRunner.cs create mode 100644 OpenNest.Tests/Api/NestRunnerTests.cs diff --git a/OpenNest.Api/NestRunner.cs b/OpenNest.Api/NestRunner.cs new file mode 100644 index 0000000..e040ad8 --- /dev/null +++ b/OpenNest.Api/NestRunner.cs @@ -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 RunAsync( + NestRequest request, + IProgress 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(); + 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(); + 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(); + 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); + } +} diff --git a/OpenNest.Tests/Api/NestRunnerTests.cs b/OpenNest.Tests/Api/NestRunnerTests.cs new file mode 100644 index 0000000..f0cd4eb --- /dev/null +++ b/OpenNest.Tests/Api/NestRunnerTests.cs @@ -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( + () => NestRunner.RunAsync(request)); + } + + [Fact] + public async Task RunAsync_EmptyParts_Throws() + { + var request = new NestRequest { Parts = [] }; + + await Assert.ThrowsAsync( + () => 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; + } +}