From 4be0b0db09fd80d770a1e774b41f614000ec0194 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Mar 2026 07:59:23 -0400 Subject: [PATCH] docs: add Nest API implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9-task plan covering: project skeleton, CutParameters migration, request/response types, NestResult rename, .opnest→.nest rename, NestWriter Stream overload, NestResponse persistence, NestRunner with multi-plate loop, and verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/superpowers/plans/2026-03-19-nest-api.md | 1039 +++++++++++++++++ 1 file changed, 1039 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-19-nest-api.md diff --git a/docs/superpowers/plans/2026-03-19-nest-api.md b/docs/superpowers/plans/2026-03-19-nest-api.md new file mode 100644 index 0000000..09c7f8c --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-nest-api.md @@ -0,0 +1,1039 @@ +# Nest API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create `OpenNest.Api` project with `NestRequest`/`NestResponse` facade, rename internal types, and standardize file extensions. + +**Architecture:** New `OpenNest.Api` class library wraps Engine + IO + Timing behind a stateless `NestRunner.RunAsync()`. Renames `NestResult` → `OptimizationResult`, replaces old `CutParameters` in Core with new `OpenNest.Api` namespace version, and renames `.opnest` → `.nest` across the solution. + +**Tech Stack:** .NET 8, C#, xUnit, System.IO.Compression (ZIP), System.Text.Json + +**Spec:** `docs/superpowers/specs/2026-03-19-nest-api-design.md` + +**Out of scope:** Consumer integration (MCP `nest_and_quote` tool, Console app simplification) — covered in a follow-up plan. + +--- + +## File Map + +### New Files (OpenNest.Api) +| File | Responsibility | +|------|---------------| +| `OpenNest.Api/OpenNest.Api.csproj` | Project file referencing Core, Engine, IO | +| `OpenNest.Api/NestStrategy.cs` | Strategy enum (Auto only for now) | +| `OpenNest.Api/NestRequestPart.cs` | Single part input (DXF path + quantity + rotation + priority) | +| `OpenNest.Api/NestRequest.cs` | Immutable nesting request | +| `OpenNest.Api/NestResponse.cs` | Immutable nesting response with SaveAsync/LoadAsync | +| `OpenNest.Api/NestRunner.cs` | Stateless orchestrator | + +### New Files (Tests) +| File | Responsibility | +|------|---------------| +| `OpenNest.Tests/Api/CutParametersTests.cs` | CutParameters defaults and construction | +| `OpenNest.Tests/Api/NestRequestTests.cs` | Request validation and immutability | +| `OpenNest.Tests/Api/NestRunnerTests.cs` | Runner integration tests (DXF import → nest → response) | +| `OpenNest.Tests/Api/NestResponsePersistenceTests.cs` | SaveAsync/LoadAsync round-trip | + +### Modified Files (Renames) +| File | Change | +|------|--------| +| `OpenNest.Core/CutParameters.cs` | Replace contents: change namespace to `OpenNest.Api`, add new properties | +| `OpenNest.Core/Timing.cs:70` | Add `using OpenNest.Api;` | +| `OpenNest.Engine/INestOptimizer.cs:10,34` | Rename `NestResult` → `OptimizationResult` | +| `OpenNest.Engine/SimulatedAnnealing.cs:20,31,108` | Update all `NestResult` references | +| `OpenNest/OpenNest.csproj` | Add ProjectReference to OpenNest.Api | +| `OpenNest/Forms/CutParametersForm.cs` | Add `using OpenNest.Api;` | +| `OpenNest/Forms/TimingForm.cs` | Add `using OpenNest.Api;` | +| `OpenNest/Forms/EditNestForm.cs` | Add `using OpenNest.Api;` | +| `OpenNest.IO/NestFormat.cs:8-9` | Change `.opnest` → `.nest` | +| `OpenNest.IO/NestWriter.cs` | Add `Write(Stream)` overload | +| `OpenNest.Console/Program.cs:194,390,393,395,403` | Update `.opnest` string literals to `.nest` | +| `OpenNest.Mcp/Tools/TestTools.cs:20,23` | Update `.opnest` references | +| `OpenNest.Mcp/Tools/InputTools.cs:24-25` | Update `.opnest` references | +| `OpenNest.Training/Program.cs:302` | Update `.opnest` reference | +| `OpenNest.Tests/OpenNest.Tests.csproj` | Add ProjectReference to OpenNest.Api | +| `OpenNest.sln` | Add OpenNest.Api project | + +--- + +### Task 1: Create OpenNest.Api Project Skeleton + +**Files:** +- Create: `OpenNest.Api/OpenNest.Api.csproj` +- Modify: `OpenNest.sln` +- Modify: `OpenNest.Tests/OpenNest.Tests.csproj` + +- [ ] **Step 1: Create the project file** + +```xml + + + + net8.0-windows + OpenNest.Api + OpenNest.Api + + + + + + + +``` + +- [ ] **Step 2: Add project to solution** + +```bash +cd "C:/Users/aisaacs/Desktop/Projects/OpenNest" +dotnet sln OpenNest.sln add OpenNest.Api/OpenNest.Api.csproj +``` + +- [ ] **Step 3: Add test project reference** + +Add to `OpenNest.Tests/OpenNest.Tests.csproj` inside the `` with other ProjectReferences: + +```xml + +``` + +- [ ] **Step 4: Build to verify** + +```bash +dotnet build OpenNest.sln +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Api/OpenNest.Api.csproj OpenNest.sln OpenNest.Tests/OpenNest.Tests.csproj +git commit -m "chore: add OpenNest.Api project skeleton" +``` + +--- + +### Task 2: Replace CutParameters in Core with OpenNest.Api Namespace + +The existing `OpenNest.CutParameters` in Core gets replaced in-place: same physical file, but new namespace (`OpenNest.Api`) and additional properties. This avoids circular references (Core doesn't reference Api) while giving all consumers the `OpenNest.Api.CutParameters` type. + +**Files:** +- Modify: `OpenNest.Core/CutParameters.cs` (replace contents) +- Modify: `OpenNest.Core/Timing.cs` (add using) +- Modify: `OpenNest/OpenNest.csproj` (add Api reference) +- Modify: `OpenNest/Forms/CutParametersForm.cs` (add using) +- Modify: `OpenNest/Forms/TimingForm.cs` (add using) +- Modify: `OpenNest/Forms/EditNestForm.cs` (add using) +- Create: `OpenNest.Tests/Api/CutParametersTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +// OpenNest.Tests/Api/CutParametersTests.cs +using OpenNest.Api; + +namespace OpenNest.Tests.Api; + +public class CutParametersTests +{ + [Fact] + public void Default_HasExpectedValues() + { + var cp = CutParameters.Default; + + Assert.Equal(100, cp.Feedrate); + Assert.Equal(300, cp.RapidTravelRate); + Assert.Equal(TimeSpan.FromSeconds(0.5), cp.PierceTime); + Assert.Equal(Units.Inches, cp.Units); + } + + [Fact] + public void Properties_AreSettable() + { + var cp = new CutParameters + { + Feedrate = 200, + RapidTravelRate = 500, + PierceTime = TimeSpan.FromSeconds(1.0), + LeadInLength = 0.25, + PostProcessor = "CL-707", + Units = Units.Millimeters + }; + + Assert.Equal(200, cp.Feedrate); + Assert.Equal(0.25, cp.LeadInLength); + Assert.Equal("CL-707", cp.PostProcessor); + } +} +``` + +- [ ] **Step 2: Replace CutParameters.cs contents** + +Replace the entire contents of `OpenNest.Core/CutParameters.cs`: + +```csharp +using System; + +namespace OpenNest.Api; + +public class CutParameters +{ + public double Feedrate { get; set; } + public double RapidTravelRate { get; set; } + public TimeSpan PierceTime { get; set; } + public double LeadInLength { get; set; } + public string PostProcessor { get; set; } + public Units Units { get; set; } + + public static CutParameters Default => new() + { + Feedrate = 100, + RapidTravelRate = 300, + PierceTime = TimeSpan.FromSeconds(0.5), + Units = OpenNest.Units.Inches + }; +} +``` + +**Note:** Properties use `{ get; set; }` (not `init`) to match existing mutation patterns in the codebase. `Units` in the `Default` property is fully qualified as `OpenNest.Units.Inches` to avoid ambiguity with the `Units` property. + +- [ ] **Step 3: Update Timing.cs** + +Add `using OpenNest.Api;` to the top of `OpenNest.Core/Timing.cs`. The `CalculateTime(TimingInfo info, CutParameters cutParams)` method at line 70 now resolves to the new type — same property names, no body changes needed. + +- [ ] **Step 4: Add Api reference to WinForms project** + +Add to `OpenNest/OpenNest.csproj` inside the `` with other ProjectReferences: + +```xml + +``` + +- [ ] **Step 5: Update WinForms forms** + +Add `using OpenNest.Api;` to each of these files: +- `OpenNest/Forms/CutParametersForm.cs` +- `OpenNest/Forms/TimingForm.cs` +- `OpenNest/Forms/EditNestForm.cs` + +No other changes — same property names, same construction pattern. + +- [ ] **Step 6: Build and run tests** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~CutParametersTests" +``` + +Expected: 2 tests pass. Full build also succeeds with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Core/CutParameters.cs OpenNest.Core/Timing.cs OpenNest/OpenNest.csproj OpenNest/Forms/CutParametersForm.cs OpenNest/Forms/TimingForm.cs OpenNest/Forms/EditNestForm.cs OpenNest.Tests/Api/CutParametersTests.cs +git commit -m "refactor: move CutParameters to OpenNest.Api namespace with new properties" +``` + +--- + +### Task 3: NestStrategy, NestRequestPart, and NestRequest + +**Files:** +- Create: `OpenNest.Api/NestStrategy.cs` +- Create: `OpenNest.Api/NestRequestPart.cs` +- Create: `OpenNest.Api/NestRequest.cs` +- Create: `OpenNest.Tests/Api/NestRequestTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +// OpenNest.Tests/Api/NestRequestTests.cs +using OpenNest.Api; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Api; + +public class NestRequestTests +{ + [Fact] + public void Default_Request_HasSensibleDefaults() + { + var request = new NestRequest(); + + Assert.Empty(request.Parts); + Assert.Equal(60, request.SheetSize.Width); + Assert.Equal(120, request.SheetSize.Length); + Assert.Equal("Steel, A1011 HR", request.Material); + Assert.Equal(0.06, request.Thickness); + Assert.Equal(0.1, request.Spacing); + Assert.Equal(NestStrategy.Auto, request.Strategy); + Assert.NotNull(request.Cutting); + } + + [Fact] + public void Parts_Accessible_AfterConstruction() + { + var request = new NestRequest + { + Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }] + }; + + Assert.Single(request.Parts); + Assert.Equal("test.dxf", request.Parts[0].DxfPath); + Assert.Equal(5, request.Parts[0].Quantity); + } + + [Fact] + public void NestRequestPart_Defaults() + { + var part = new NestRequestPart { DxfPath = "part.dxf" }; + + Assert.Equal(1, part.Quantity); + Assert.True(part.AllowRotation); + Assert.Equal(0, part.Priority); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRequestTests" --no-build +``` + +Expected: FAIL — types do not exist. + +- [ ] **Step 3: Write the implementations** + +```csharp +// OpenNest.Api/NestStrategy.cs +namespace OpenNest.Api; + +public enum NestStrategy { Auto } +``` + +```csharp +// OpenNest.Api/NestRequestPart.cs +namespace OpenNest.Api; + +public class NestRequestPart +{ + public string DxfPath { get; init; } + public int Quantity { get; init; } = 1; + public bool AllowRotation { get; init; } = true; + public int Priority { get; init; } = 0; +} +``` + +```csharp +// OpenNest.Api/NestRequest.cs +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Api; + +public class NestRequest +{ + public IReadOnlyList Parts { get; init; } = []; + public Size SheetSize { get; init; } = new(60, 120); + public string Material { get; init; } = "Steel, A1011 HR"; + public double Thickness { get; init; } = 0.06; + public double Spacing { get; init; } = 0.1; + public NestStrategy Strategy { get; init; } = NestStrategy.Auto; + public CutParameters Cutting { get; init; } = CutParameters.Default; +} +``` + +- [ ] **Step 4: Build and run tests** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRequestTests" +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Api/NestStrategy.cs OpenNest.Api/NestRequestPart.cs OpenNest.Api/NestRequest.cs OpenNest.Tests/Api/NestRequestTests.cs +git commit -m "feat(api): add NestStrategy, NestRequestPart, NestRequest" +``` + +--- + +### Task 4: Rename NestResult → OptimizationResult + +**Files:** +- Modify: `OpenNest.Engine/INestOptimizer.cs` (class name + return type) +- Modify: `OpenNest.Engine/SimulatedAnnealing.cs` (all references) + +- [ ] **Step 1: Rename in INestOptimizer.cs** + +In `OpenNest.Engine/INestOptimizer.cs`: +- Line 10: `public class NestResult` → `public class OptimizationResult` +- Line 34: return type `NestResult` → `OptimizationResult` + +- [ ] **Step 2: Rename in SimulatedAnnealing.cs** + +In `OpenNest.Engine/SimulatedAnnealing.cs`: +- Line 20: return type `NestResult` → `OptimizationResult` +- Line 31: `new NestResult` → `new OptimizationResult` +- Line 108: `new NestResult` → `new OptimizationResult` + +- [ ] **Step 3: Build and run all tests** + +```bash +dotnet build OpenNest.sln && dotnet test OpenNest.Tests +``` + +Expected: Build succeeds, all existing tests pass. If there are additional `NestResult` references the build will reveal them — fix any that appear. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/INestOptimizer.cs OpenNest.Engine/SimulatedAnnealing.cs +git commit -m "refactor(engine): rename NestResult to OptimizationResult" +``` + +--- + +### Task 5: Rename .opnest → .nest File Extension + +**Files:** +- Modify: `OpenNest.IO/NestFormat.cs:8-9` +- Modify: `OpenNest.Console/Program.cs:194,390,393,395,403` +- Modify: `OpenNest.Mcp/Tools/TestTools.cs:20,23` +- Modify: `OpenNest.Mcp/Tools/InputTools.cs:24-25` +- Modify: `OpenNest.Training/Program.cs:302` + +- [ ] **Step 1: Update NestFormat.cs** + +In `OpenNest.IO/NestFormat.cs`: +- Line 8: `".opnest"` → `".nest"` +- Line 9: `"Nest Files (*.opnest)|*.opnest"` → `"Nest Files (*.nest)|*.nest"` + +- [ ] **Step 2: Update Console/Program.cs** + +Replace all `.opnest` string literals with `.nest`: +- Line 194: `(.opnest)` → `(.nest)` +- Line 390: `.opnest` → `.nest` +- Line 393: `` → `` +- Line 395: `` → `` +- Line 403: `-result.opnest` → `-result.nest` + +- [ ] **Step 3: Update MCP tools** + +In `OpenNest.Mcp/Tools/TestTools.cs`: +- Line 20: change `.opnest` → `.nest` in default path +- Line 23: change `.opnest` → `.nest` in description + +In `OpenNest.Mcp/Tools/InputTools.cs`: +- Line 24: change `.opnest` → `.nest` in description +- Line 25: change `.opnest` → `.nest` in description + +- [ ] **Step 4: Update Training/Program.cs** + +- Line 302: change `.opnest` → `.nest` + +- [ ] **Step 5: Verify no remaining .opnest references in code** + +```bash +grep -r "\.opnest" --include="*.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest" +``` + +Expected: No results. + +- [ ] **Step 6: Build to verify** + +```bash +dotnet build OpenNest.sln +``` + +Expected: Build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.IO/NestFormat.cs OpenNest.Console/Program.cs OpenNest.Mcp/Tools/TestTools.cs OpenNest.Mcp/Tools/InputTools.cs OpenNest.Training/Program.cs +git commit -m "refactor: rename .opnest file extension to .nest" +``` + +--- + +### Task 6: Add NestWriter.Write(Stream) Overload + +The existing `NestWriter.Write(string)` creates a `FileStream` and `ZipArchive` internally. We need a `Write(Stream)` overload so `NestResponse.SaveAsync` can write the `.nest` payload to a `MemoryStream` for embedding inside the `.nestquote` ZIP. + +**Files:** +- Modify: `OpenNest.IO/NestWriter.cs` + +- [ ] **Step 1: Add Write(Stream) overload** + +In `OpenNest.IO/NestWriter.cs`, add a new method after the existing `Write(string)` method (after line 43): + +```csharp +public bool Write(Stream stream) +{ + nest.DateLastModified = DateTime.Now; + SetDrawingIds(); + + using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); + + WriteNestJson(zipArchive); + WritePrograms(zipArchive); + WriteBestFits(zipArchive); + + return true; +} +``` + +**Note:** `leaveOpen: true` is critical — without it, `ZipArchive.Dispose()` would close the underlying `MemoryStream`, preventing the caller from reading it afterwards. + +- [ ] **Step 2: Refactor Write(string) to delegate** + +Replace the existing `Write(string)` method body to delegate to the new overload: + +```csharp +public bool Write(string file) +{ + using var fileStream = new FileStream(file, FileMode.Create); + return Write(fileStream); +} +``` + +- [ ] **Step 3: Build and run all tests** + +```bash +dotnet build OpenNest.sln && dotnet test OpenNest.Tests +``` + +Expected: Build succeeds, all existing tests pass (NestWriter behavior unchanged for file-based callers). + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.IO/NestWriter.cs +git commit -m "feat(io): add NestWriter.Write(Stream) overload" +``` + +--- + +### Task 7: NestResponse Type with SaveAsync/LoadAsync + +**Files:** +- Create: `OpenNest.Api/NestResponse.cs` +- Create: `OpenNest.Tests/Api/NestResponsePersistenceTests.cs` + +- [ ] **Step 1: Write the round-trip test** + +```csharp +// OpenNest.Tests/Api/NestResponsePersistenceTests.cs +using System; +using System.IO; +using System.Threading.Tasks; +using OpenNest.Api; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Api; + +public class NestResponsePersistenceTests +{ + [Fact] + public async Task SaveAsync_LoadAsync_RoundTrips() + { + var nest = new Nest("test-nest"); + var plate = new Plate(new Size(60, 120)); + nest.Plates.Add(plate); + + var request = new NestRequest + { + Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }], + SheetSize = new Size(60, 120), + 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), + Nest = nest, + Request = request + }; + + var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote"); + + try + { + await original.SaveAsync(path); + var loaded = await NestResponse.LoadAsync(path); + + 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(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(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath); + Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity); + + Assert.NotNull(loaded.Nest); + Assert.Equal(1, loaded.Nest.Plates.Count); + } + finally + { + File.Delete(path); + } + } +} +``` + +- [ ] **Step 2: Write NestResponse implementation** + +```csharp +// OpenNest.Api/NestResponse.cs +using System; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Threading.Tasks; +using OpenNest.IO; + +namespace OpenNest.Api; + +public class NestResponse +{ + public int SheetCount { get; init; } + public double Utilization { get; init; } + public TimeSpan CutTime { get; init; } + public TimeSpan Elapsed { get; init; } + public Nest Nest { get; init; } + public NestRequest Request { get; init; } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + IncludeFields = true // Required for OpenNest.Geometry.Size (public fields) + }; + + public async Task SaveAsync(string path) + { + 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 + }; + var responseEntry = zip.CreateEntry("response.json"); + await using (var stream = responseEntry.Open()) + { + await JsonSerializer.SerializeAsync(stream, metrics, 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); + nestMs.Position = 0; + await using (var stream = nestEntry.Open()) + { + await nestMs.CopyToAsync(stream); + } + } + + public static async Task LoadAsync(string path) + { + 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"); + NestRequest request; + await using (var stream = requestEntry!.Open()) + { + request = await JsonSerializer.DeserializeAsync(stream, JsonOptions); + } + + // Read response.json + var responseEntry = zip.GetEntry("response.json"); + JsonElement metricsJson; + await using (var stream = responseEntry!.Open()) + { + metricsJson = await JsonSerializer.DeserializeAsync(stream, JsonOptions); + } + + // Read embedded nest.nest via NestReader(Stream) + var nestEntry = zip.GetEntry("nest.nest"); + Nest nest; + using (var nestMs = new MemoryStream()) + { + await using (var stream = nestEntry!.Open()) + { + await stream.CopyToAsync(nestMs); + } + nestMs.Position = 0; + var reader = new NestReader(nestMs); + nest = reader.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()), + Nest = nest, + Request = request + }; + } +} +``` + +**Key details:** +- `IncludeFields = true` in `JsonOptions` — required because `OpenNest.Geometry.Size` is a struct with public fields (`Width`, `Length`), not properties. Without this, Size serializes as `{}`. +- `NestReader(Stream)` constructor exists at `OpenNest.IO/NestReader.cs:26`. Its `Read()` method disposes the stream, which is fine since we use a separate `MemoryStream`. +- `NestWriter.Write(Stream)` was added in Task 6. + +- [ ] **Step 3: Build and run tests** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestResponsePersistenceTests" +``` + +Expected: 1 test passes. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Api/NestResponse.cs OpenNest.Tests/Api/NestResponsePersistenceTests.cs +git commit -m "feat(api): add NestResponse with SaveAsync/LoadAsync" +``` + +--- + +### Task 8: NestRunner Implementation + +**Files:** +- Create: `OpenNest.Api/NestRunner.cs` +- Create: `OpenNest.Tests/Api/NestRunnerTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +// OpenNest.Tests/Api/NestRunnerTests.cs +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; + } +} +``` + +**API notes:** +- `Shape()` — parameterless constructor, then add to `shape.Entities` list +- `ConvertGeometry.ToProgram(Shape)` — takes a Shape directly +- `DxfExporter.ExportProgram(Program, string)` — exports a program to a DXF file path + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRunnerTests" --no-build +``` + +Expected: FAIL — `NestRunner` does not exist. + +- [ ] **Step 3: Write NestRunner implementation** + +```csharp +// OpenNest.Api/NestRunner.cs +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 (placeholder — Timing will be reworked later) + 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); + } +} +``` + +**Key API details:** +- `Drawing(string name)` constructor (confirmed in `NestReader.cs:81`), then set `drawing.Program = pgm` +- `Part.BaseDrawing` (public readonly field, confirmed in `Part.cs:24` and `NestWriter.cs:154`) +- `ConvertGeometry.ToProgram(IList)` for the DXF geometry list +- `engine.Nest(List, IProgress, CancellationToken)` returns `List` +- Method returns `Task.FromResult` since the actual work is synchronous (engine is CPU-bound, not async). The `Task` return type keeps the API async-friendly for future use. + +- [ ] **Step 4: Build and run tests** + +```bash +dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRunnerTests" +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Api/NestRunner.cs OpenNest.Tests/Api/NestRunnerTests.cs +git commit -m "feat(api): add NestRunner with multi-plate loop" +``` + +--- + +### Task 9: Full Integration Test and Final Verification + +**Files:** +- None new — verification only + +- [ ] **Step 1: Run full solution build** + +```bash +cd "C:/Users/aisaacs/Desktop/Projects/OpenNest" +dotnet build OpenNest.sln +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 2: Run all tests** + +```bash +dotnet test OpenNest.Tests +``` + +Expected: All tests pass — both existing and new Api tests. + +- [ ] **Step 3: Verify no remaining .opnest in code** + +```bash +grep -r "\.opnest" --include="*.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest" +``` + +Expected: No results. + +- [ ] **Step 4: Verify no remaining old NestResult in code** + +```bash +grep -r "\bNestResult\b" --include="*.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest" | grep -v "StripNestResult" | grep -v "OptimizationResult" | grep -v "NestResponse" +``` + +Expected: No results (StripNestResult is a separate internal type, fine to keep). + +- [ ] **Step 5: Verify CutParameters namespace** + +```bash +grep -rn "namespace OpenNest;" --include="CutParameters.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest" +``` + +Expected: No results — the file should have `namespace OpenNest.Api;`.