Files
OpenNest/docs/superpowers/plans/2026-03-19-nest-api.md
AJ Isaacs 4be0b0db09 docs: add Nest API implementation plan
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>
2026-03-19 07:59:23 -04:00

32 KiB

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 NestResultOptimizationResult, 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 NestResultOptimizationResult
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

<!-- 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
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:

<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
  • Step 4: Build to verify
dotnet build OpenNest.sln

Expected: Build succeeds with no errors.

  • Step 5: Commit
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

// 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:

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:

<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
dotnet test OpenNest.Tests --filter "FullyQualifiedName~CutParametersTests"

Expected: 2 tests pass. Full build also succeeds with no errors.

  • Step 7: Commit
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

// 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
dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRequestTests" --no-build

Expected: FAIL — types do not exist.

  • Step 3: Write the implementations
// OpenNest.Api/NestStrategy.cs
namespace OpenNest.Api;

public enum NestStrategy { Auto }
// 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;
}
// 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
dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRequestTests"

Expected: 3 tests pass.

  • Step 5: Commit
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 NestResultpublic class OptimizationResult

  • Line 34: return type NestResultOptimizationResult

  • Step 2: Rename in SimulatedAnnealing.cs

In OpenNest.Engine/SimulatedAnnealing.cs:

  • Line 20: return type NestResultOptimizationResult

  • Line 31: new NestResultnew OptimizationResult

  • Line 108: new NestResultnew OptimizationResult

  • Step 3: Build and run all tests

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
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

grep -r "\.opnest" --include="*.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest"

Expected: No results.

  • Step 6: Build to verify
dotnet build OpenNest.sln

Expected: Build succeeds.

  • Step 7: Commit
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):

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:

public bool Write(string file)
{
    using var fileStream = new FileStream(file, FileMode.Create);
    return Write(fileStream);
}
  • Step 3: Build and run all tests
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
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

// 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
// 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

dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestResponsePersistenceTests"

Expected: 1 test passes.

  • Step 4: Commit
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

// 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

dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRunnerTests" --no-build

Expected: FAIL — NestRunner does not exist.

  • Step 3: Write NestRunner implementation
// 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

dotnet test OpenNest.Tests --filter "FullyQualifiedName~NestRunnerTests"

Expected: 3 tests pass.

  • Step 5: Commit
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

cd "C:/Users/aisaacs/Desktop/Projects/OpenNest"
dotnet build OpenNest.sln

Expected: Build succeeds with no errors.

  • Step 2: Run all tests
dotnet test OpenNest.Tests

Expected: All tests pass — both existing and new Api tests.

  • Step 3: Verify no remaining .opnest in code
grep -r "\.opnest" --include="*.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest"

Expected: No results.

  • Step 4: Verify no remaining old NestResult in code
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
grep -rn "namespace OpenNest;" --include="CutParameters.cs" "C:/Users/aisaacs/Desktop/Projects/OpenNest"

Expected: No results — the file should have namespace OpenNest.Api;.