4be0b0db09
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) <noreply@anthropic.com>
1040 lines
32 KiB
Markdown
1040 lines
32 KiB
Markdown
# 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
|
|
<!-- OpenNest.Api/OpenNest.Api.csproj -->
|
|
<Project Sdk="Microsoft.NET.Sdk">
|
|
<PropertyGroup>
|
|
<TargetFramework>net8.0-windows</TargetFramework>
|
|
<RootNamespace>OpenNest.Api</RootNamespace>
|
|
<AssemblyName>OpenNest.Api</AssemblyName>
|
|
</PropertyGroup>
|
|
<ItemGroup>
|
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
|
</ItemGroup>
|
|
</Project>
|
|
```
|
|
|
|
- [ ] **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 `<ItemGroup>` with other ProjectReferences:
|
|
|
|
```xml
|
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
|
```
|
|
|
|
- [ ] **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 `<ItemGroup>` with other ProjectReferences:
|
|
|
|
```xml
|
|
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
|
|
```
|
|
|
|
- [ ] **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<NestRequestPart> 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: `<nest.opnest>` → `<nest.nest>`
|
|
- Line 395: `<nest.opnest>` → `<nest.nest>`
|
|
- 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<NestResponse> 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<NestRequest>(stream, JsonOptions);
|
|
}
|
|
|
|
// Read response.json
|
|
var responseEntry = zip.GetEntry("response.json");
|
|
JsonElement metricsJson;
|
|
await using (var stream = responseEntry!.Open())
|
|
{
|
|
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(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<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;
|
|
}
|
|
}
|
|
```
|
|
|
|
**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<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 (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<Entity>)` for the DXF geometry list
|
|
- `engine.Nest(List<NestItem>, IProgress<NestProgress>, CancellationToken)` returns `List<Part>`
|
|
- 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;`.
|