Files
OpenNest/docs/superpowers/plans/2026-03-16-engine-refactor.md
AJ Isaacs 4c4e8c37fb docs: add engine refactor implementation plan
11 tasks across 3 chunks: extract AccumulatingProgress, ShrinkFiller,
AngleCandidateBuilder, PairFiller, RemnantFiller, then rewire both
engines and NestEngineBase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:05:51 -04:00

53 KiB

Engine Refactor Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extract shared algorithms from DefaultNestEngine and StripNestEngine into focused, reusable helper classes.

Architecture: Five helper classes (AccumulatingProgress, ShrinkFiller, AngleCandidateBuilder, PairFiller, RemnantFiller) are extracted from the two engines. Each engine then composes these helpers instead of inlining the logic. No new interfaces — just focused classes with delegate-based decoupling.

Tech Stack: .NET 8, C#, xUnit

Spec: docs/superpowers/specs/2026-03-16-engine-refactor-design.md


File Structure

New files (OpenNest.Engine/):

  • AccumulatingProgress.cs — IProgress wrapper that prepends prior parts
  • ShrinkFiller.cs — Static shrink-to-fit loop + ShrinkAxis enum + ShrinkResult class
  • AngleCandidateBuilder.cs — Angle sweep/ML/pruning with known-good state
  • PairFiller.cs — Pair candidate selection and fill loop
  • RemnantFiller.cs — Iterative remnant-fill loop over RemnantFinder

New test files (OpenNest.Tests/):

  • AccumulatingProgressTests.cs
  • ShrinkFillerTests.cs
  • AngleCandidateBuilderTests.cs
  • PairFillerTests.cs
  • RemnantFillerTests.cs
  • EngineRefactorSmokeTests.cs — Integration tests verifying engines produce same results

Modified files:

  • OpenNest.Engine/DefaultNestEngine.cs — Remove extracted code, compose helpers
  • OpenNest.Engine/StripNestEngine.cs — Remove extracted code, compose helpers
  • OpenNest.Engine/NestEngineBase.cs — Replace remnant loop with RemnantFiller

Chunk 1: Leaf Extractions (AccumulatingProgress, ShrinkFiller)

Task 0: Add InternalsVisibleTo for test project

Files:

  • Modify: OpenNest.Engine/OpenNest.Engine.csproj

Several extracted classes are internal. The test project needs access.

  • Step 1: Add InternalsVisibleTo to the Engine csproj

Add inside the first <PropertyGroup> or add a new <ItemGroup>:

<ItemGroup>
    <InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
  • Step 2: Build to verify

Run: dotnet build OpenNest.sln Expected: Build succeeds.

  • Step 3: Commit
git add OpenNest.Engine/OpenNest.Engine.csproj
git commit -m "build: add InternalsVisibleTo for OpenNest.Tests"

Task 1: Extract AccumulatingProgress

Files:

  • Create: OpenNest.Engine/AccumulatingProgress.cs

  • Create: OpenNest.Tests/AccumulatingProgressTests.cs

  • Step 1: Write the failing test

In OpenNest.Tests/AccumulatingProgressTests.cs:

namespace OpenNest.Tests;

public class AccumulatingProgressTests
{
    private class CapturingProgress : IProgress<NestProgress>
    {
        public NestProgress Last { get; private set; }
        public void Report(NestProgress value) => Last = value;
    }

    [Fact]
    public void Report_PrependsPreviousParts()
    {
        var inner = new CapturingProgress();
        var previous = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
        var accumulating = new AccumulatingProgress(inner, previous);

        var newParts = new List<Part> { TestHelpers.MakePartAt(20, 0, 10) };
        accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });

        Assert.NotNull(inner.Last);
        Assert.Equal(2, inner.Last.BestParts.Count);
        Assert.Equal(2, inner.Last.BestPartCount);
    }

    [Fact]
    public void Report_NoPreviousParts_PassesThrough()
    {
        var inner = new CapturingProgress();
        var accumulating = new AccumulatingProgress(inner, new List<Part>());

        var newParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
        accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });

        Assert.NotNull(inner.Last);
        Assert.Single(inner.Last.BestParts);
    }

    [Fact]
    public void Report_NullBestParts_PassesThrough()
    {
        var inner = new CapturingProgress();
        var previous = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
        var accumulating = new AccumulatingProgress(inner, previous);

        accumulating.Report(new NestProgress { BestParts = null });

        Assert.NotNull(inner.Last);
        Assert.Null(inner.Last.BestParts);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n Expected: Build error — AccumulatingProgress class does not exist yet.

  • Step 3: Create AccumulatingProgress class

In OpenNest.Engine/AccumulatingProgress.cs:

using System;
using System.Collections.Generic;

namespace OpenNest
{
    /// <summary>
    /// Wraps an IProgress to prepend previously placed parts to each report,
    /// so the UI shows the full picture during incremental fills.
    /// </summary>
    internal class AccumulatingProgress : IProgress<NestProgress>
    {
        private readonly IProgress<NestProgress> inner;
        private readonly List<Part> previousParts;

        public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts)
        {
            this.inner = inner;
            this.previousParts = previousParts;
        }

        public void Report(NestProgress value)
        {
            if (value.BestParts != null && previousParts.Count > 0)
            {
                var combined = new List<Part>(previousParts.Count + value.BestParts.Count);
                combined.AddRange(previousParts);
                combined.AddRange(value.BestParts);
                value.BestParts = combined;
                value.BestPartCount = combined.Count;
            }

            inner.Report(value);
        }
    }
}

Note: The class is internal. The test project needs InternalsVisibleTo. Check if OpenNest.Engine.csproj already has it; if not, add [assembly: InternalsVisibleTo("OpenNest.Tests")] in OpenNest.Engine/Properties/AssemblyInfo.cs or as an <InternalsVisibleTo> item in the csproj.

  • Step 4: Run test to verify it passes

Run: dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n Expected: All 3 tests PASS.

  • Step 5: Commit
git add OpenNest.Engine/AccumulatingProgress.cs OpenNest.Tests/AccumulatingProgressTests.cs
git commit -m "refactor(engine): extract AccumulatingProgress from StripNestEngine"

Task 2: Extract ShrinkFiller

Files:

  • Create: OpenNest.Engine/ShrinkFiller.cs

  • Create: OpenNest.Tests/ShrinkFillerTests.cs

  • Step 1: Write the failing tests

In OpenNest.Tests/ShrinkFillerTests.cs:

using OpenNest.Geometry;

namespace OpenNest.Tests;

public class ShrinkFillerTests
{
    private static Drawing MakeSquareDrawing(double size)
    {
        var pgm = new OpenNest.CNC.Program();
        pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
        return new Drawing("square", pgm);
    }

    [Fact]
    public void Shrink_ReducesDimension_UntilCountDrops()
    {
        // 10x10 parts on a 100x50 box with 1.0 spacing.
        // Initial fill should place multiple parts. Shrinking height
        // should eventually reduce to the tightest strip.
        var drawing = MakeSquareDrawing(10);
        var item = new NestItem { Drawing = drawing };
        var box = new Box(0, 0, 100, 50);

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        {
            var plate = new Plate(b.Width, b.Length);
            var engine = new DefaultNestEngine(plate);
            return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
        };

        var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height);

        Assert.NotNull(result);
        Assert.True(result.Parts.Count > 0);
        Assert.True(result.Dimension <= 50, "Dimension should be <= original");
        Assert.True(result.Dimension > 0);
    }

    [Fact]
    public void Shrink_Width_ReducesHorizontally()
    {
        var drawing = MakeSquareDrawing(10);
        var item = new NestItem { Drawing = drawing };
        var box = new Box(0, 0, 100, 50);

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        {
            var plate = new Plate(b.Width, b.Length);
            var engine = new DefaultNestEngine(plate);
            return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
        };

        var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Width);

        Assert.NotNull(result);
        Assert.True(result.Parts.Count > 0);
        Assert.True(result.Dimension <= 100);
    }

    [Fact]
    public void Shrink_RespectsMaxIterations()
    {
        var callCount = 0;
        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        {
            callCount++;
            return new List<Part> { TestHelpers.MakePartAt(0, 0, 5) };
        };

        var item = new NestItem { Drawing = MakeSquareDrawing(5) };
        var box = new Box(0, 0, 100, 100);

        ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height, maxIterations: 3);

        // 1 initial + up to 3 shrink iterations = max 4 calls
        Assert.True(callCount <= 4);
    }

    [Fact]
    public void Shrink_RespectsCancellation()
    {
        var cts = new System.Threading.CancellationTokenSource();
        cts.Cancel();

        var drawing = MakeSquareDrawing(10);
        var item = new NestItem { Drawing = drawing };
        var box = new Box(0, 0, 100, 50);

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
            new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };

        var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0,
            ShrinkAxis.Height, token: cts.Token);

        // Should return initial fill without shrinking
        Assert.NotNull(result);
        Assert.True(result.Parts.Count > 0);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n Expected: Build error — ShrinkFiller, ShrinkAxis, ShrinkResult do not exist.

  • Step 3: Create ShrinkFiller class

In OpenNest.Engine/ShrinkFiller.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;

namespace OpenNest
{
    public enum ShrinkAxis { Width, Height }

    public class ShrinkResult
    {
        public List<Part> Parts { get; set; }
        public double Dimension { get; set; }
    }

    /// <summary>
    /// Fills a box then iteratively shrinks one axis by the spacing amount
    /// until the part count drops. Returns the tightest box that still fits
    /// the same number of parts.
    /// </summary>
    public static class ShrinkFiller
    {
        public static ShrinkResult Shrink(
            Func<NestItem, Box, List<Part>> fillFunc,
            NestItem item, Box box,
            double spacing,
            ShrinkAxis axis,
            CancellationToken token = default,
            int maxIterations = 20)
        {
            var parts = fillFunc(item, box);

            if (parts == null || parts.Count == 0)
                return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };

            var targetCount = parts.Count;
            var bestParts = parts;
            var bestDim = MeasureDimension(parts, box, axis);

            for (var i = 0; i < maxIterations; i++)
            {
                if (token.IsCancellationRequested)
                    break;

                var trialDim = bestDim - spacing;
                if (trialDim <= 0)
                    break;

                var trialBox = axis == ShrinkAxis.Width
                    ? new Box(box.X, box.Y, trialDim, box.Length)
                    : new Box(box.X, box.Y, box.Width, trialDim);

                var trialParts = fillFunc(item, trialBox);

                if (trialParts == null || trialParts.Count < targetCount)
                    break;

                bestParts = trialParts;
                bestDim = MeasureDimension(trialParts, box, axis);
            }

            return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
        }

        private static double MeasureDimension(List<Part> parts, Box box, ShrinkAxis axis)
        {
            var placedBox = parts.Cast<IBoundable>().GetBoundingBox();

            return axis == ShrinkAxis.Width
                ? placedBox.Right - box.X
                : placedBox.Top - box.Y;
        }
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n Expected: All 4 tests PASS.

  • Step 5: Commit
git add OpenNest.Engine/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
git commit -m "refactor(engine): extract ShrinkFiller from StripNestEngine"

Chunk 2: AngleCandidateBuilder and PairFiller

Task 3: Extract AngleCandidateBuilder

Files:

  • Create: OpenNest.Engine/AngleCandidateBuilder.cs

  • Create: OpenNest.Tests/AngleCandidateBuilderTests.cs

  • Modify: OpenNest.Engine/DefaultNestEngine.cs — Add forwarding property

  • Step 1: Write the failing tests

In OpenNest.Tests/AngleCandidateBuilderTests.cs:

using OpenNest.Geometry;

namespace OpenNest.Tests;

public class AngleCandidateBuilderTests
{
    private static Drawing MakeRectDrawing(double w, double h)
    {
        var pgm = new OpenNest.CNC.Program();
        pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
        return new Drawing("rect", pgm);
    }

    [Fact]
    public void Build_ReturnsAtLeastTwoAngles()
    {
        var builder = new AngleCandidateBuilder();
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
        var workArea = new Box(0, 0, 100, 100);

        var angles = builder.Build(item, 0, workArea);

        Assert.True(angles.Count >= 2);
    }

    [Fact]
    public void Build_NarrowWorkArea_ProducesMoreAngles()
    {
        var builder = new AngleCandidateBuilder();
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
        var wideArea = new Box(0, 0, 100, 100);
        var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side

        var wideAngles = builder.Build(item, 0, wideArea);
        var narrowAngles = builder.Build(item, 0, narrowArea);

        Assert.True(narrowAngles.Count > wideAngles.Count,
            $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
    }

    [Fact]
    public void ForceFullSweep_ProducesFullSweep()
    {
        var builder = new AngleCandidateBuilder { ForceFullSweep = true };
        var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
        var workArea = new Box(0, 0, 100, 100);

        var angles = builder.Build(item, 0, workArea);

        // Full sweep at 5° steps = ~36 angles (0 to 175), plus base angles
        Assert.True(angles.Count > 10);
    }

    [Fact]
    public void RecordProductive_PrunesSubsequentBuilds()
    {
        var builder = new AngleCandidateBuilder { ForceFullSweep = true };
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
        var workArea = new Box(0, 0, 100, 8);

        // First build — full sweep
        var firstAngles = builder.Build(item, 0, workArea);

        // Record some as productive
        var productive = new List<AngleResult>
        {
            new AngleResult { AngleDeg = 0, PartCount = 5 },
            new AngleResult { AngleDeg = 45, PartCount = 3 },
        };
        builder.RecordProductive(productive);

        // Second build — should be pruned to known-good + base angles
        builder.ForceFullSweep = false;
        var secondAngles = builder.Build(item, 0, workArea);

        Assert.True(secondAngles.Count < firstAngles.Count,
            $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n Expected: Build error — AngleCandidateBuilder does not exist.

  • Step 3: Create AngleCandidateBuilder class

In OpenNest.Engine/AngleCandidateBuilder.cs:

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Math;

namespace OpenNest
{
    /// <summary>
    /// Builds candidate rotation angles for single-item fill. Encapsulates the
    /// full pipeline: base angles, narrow-area sweep, ML prediction, and
    /// known-good pruning across fills.
    /// </summary>
    public class AngleCandidateBuilder
    {
        private readonly HashSet<double> knownGoodAngles = new();

        public bool ForceFullSweep { get; set; }

        public List<double> Build(NestItem item, double bestRotation, Box workArea)
        {
            var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };

            var testPart = new Part(item.Drawing);
            if (!bestRotation.IsEqualTo(0))
                testPart.Rotate(bestRotation);
            testPart.UpdateBounds();

            var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
            var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
            var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep;

            if (needsSweep)
            {
                var step = Angle.ToRadians(5);
                for (var a = 0.0; a < System.Math.PI; a += step)
                {
                    if (!angles.Any(existing => existing.IsEqualTo(a)))
                        angles.Add(a);
                }
            }

            if (!ForceFullSweep && angles.Count > 2)
            {
                var features = FeatureExtractor.Extract(item.Drawing);
                if (features != null)
                {
                    var predicted = AnglePredictor.PredictAngles(
                        features, workArea.Width, workArea.Length);

                    if (predicted != null)
                    {
                        var mlAngles = new List<double>(predicted);

                        if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
                            mlAngles.Add(bestRotation);
                        if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
                            mlAngles.Add(bestRotation + Angle.HalfPI);

                        Debug.WriteLine($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
                        angles = mlAngles;
                    }
                }
            }

            if (knownGoodAngles.Count > 0 && !ForceFullSweep)
            {
                var pruned = new List<double> { bestRotation, bestRotation + Angle.HalfPI };

                foreach (var a in knownGoodAngles)
                {
                    if (!pruned.Any(existing => existing.IsEqualTo(a)))
                        pruned.Add(a);
                }

                Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
                return pruned;
            }

            return angles;
        }

        /// <summary>
        /// Records angles that produced results. These are used to prune
        /// subsequent Build() calls.
        /// </summary>
        public void RecordProductive(List<AngleResult> angleResults)
        {
            foreach (var ar in angleResults)
            {
                if (ar.PartCount > 0)
                    knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
            }
        }
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n Expected: All 4 tests PASS.

  • Step 5: Commit
git add OpenNest.Engine/AngleCandidateBuilder.cs OpenNest.Tests/AngleCandidateBuilderTests.cs
git commit -m "refactor(engine): extract AngleCandidateBuilder from DefaultNestEngine"

Task 4: Make BuildRotatedPattern and FillPattern internal static

Files:

  • Modify: OpenNest.Engine/DefaultNestEngine.cs:493-546

This task prepares the pattern helpers for use by PairFiller. No tests needed — existing behavior is unchanged.

  • Step 1: Change method signatures to internal static

In DefaultNestEngine.cs, change BuildRotatedPattern (line 493):

  • From: private Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
  • To: internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)

Change FillPattern (line 513):

  • From: private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
  • To: internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)

These methods already have no instance state access — they only use their parameters. Verify this by confirming no this. or instance field/property references in their bodies.

  • Step 2: Build to verify no regressions

Run: dotnet build OpenNest.sln Expected: Build succeeds.

  • Step 3: Run all tests

Run: dotnet test OpenNest.Tests -v n Expected: All tests PASS.

  • Step 4: Commit
git add OpenNest.Engine/DefaultNestEngine.cs
git commit -m "refactor(engine): make BuildRotatedPattern and FillPattern internal static"

Task 5: Extract PairFiller

Files:

  • Create: OpenNest.Engine/PairFiller.cs

  • Create: OpenNest.Tests/PairFillerTests.cs

  • Step 1: Write the failing tests

In OpenNest.Tests/PairFillerTests.cs:

using OpenNest.Geometry;

namespace OpenNest.Tests;

public class PairFillerTests
{
    private static Drawing MakeRectDrawing(double w, double h)
    {
        var pgm = new OpenNest.CNC.Program();
        pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
        return new Drawing("rect", pgm);
    }

    [Fact]
    public void Fill_ReturnsPartsForSimpleDrawing()
    {
        var plateSize = new Size(120, 60);
        var filler = new PairFiller(plateSize, 0.5);
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
        var workArea = new Box(0, 0, 120, 60);

        var parts = filler.Fill(item, workArea);

        Assert.NotNull(parts);
        // Pair filling may or may not find interlocking pairs for rectangles,
        // but should return a non-null list.
    }

    [Fact]
    public void Fill_EmptyResult_WhenPartTooLarge()
    {
        var plateSize = new Size(10, 10);
        var filler = new PairFiller(plateSize, 0.5);
        var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
        var workArea = new Box(0, 0, 10, 10);

        var parts = filler.Fill(item, workArea);

        Assert.NotNull(parts);
        Assert.Empty(parts);
    }

    [Fact]
    public void Fill_RespectsCancellation()
    {
        var cts = new System.Threading.CancellationTokenSource();
        cts.Cancel();

        var plateSize = new Size(120, 60);
        var filler = new PairFiller(plateSize, 0.5);
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
        var workArea = new Box(0, 0, 120, 60);

        var parts = filler.Fill(item, workArea, token: cts.Token);

        // Should return empty or partial — not throw
        Assert.NotNull(parts);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test OpenNest.Tests --filter "PairFillerTests" -v n Expected: Build error — PairFiller does not exist.

  • Step 3: Create PairFiller class

In OpenNest.Engine/PairFiller.cs:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;

namespace OpenNest
{
    /// <summary>
    /// Fills a work area using interlocking part pairs from BestFitCache.
    /// Extracted from DefaultNestEngine.FillWithPairs.
    /// </summary>
    public class PairFiller
    {
        private const int MinPairCandidates = 10;
        private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3);

        private readonly Size plateSize;
        private readonly double partSpacing;

        public PairFiller(Size plateSize, double partSpacing)
        {
            this.plateSize = plateSize;
            this.partSpacing = partSpacing;
        }

        public List<Part> Fill(NestItem item, Box workArea,
            int plateNumber = 0,
            CancellationToken token = default,
            IProgress<NestProgress> progress = null)
        {
            var bestFits = BestFitCache.GetOrCompute(
                item.Drawing, plateSize.Width, plateSize.Length, partSpacing);

            var candidates = SelectPairCandidates(bestFits, workArea);
            var remainderPatterns = BuildRemainderPatterns(candidates, item);
            Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
            Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");

            List<Part> best = null;
            var bestScore = default(FillScore);
            var deadline = Stopwatch.StartNew();

            try
            {
                for (var i = 0; i < candidates.Count; i++)
                {
                    token.ThrowIfCancellationRequested();

                    if (i >= MinPairCandidates && deadline.Elapsed > PairTimeLimit)
                    {
                        Debug.WriteLine($"[PairFiller] Time limit at {i + 1}/{candidates.Count} ({deadline.ElapsedMilliseconds}ms)");
                        break;
                    }

                    var result = candidates[i];
                    var pairParts = result.BuildParts(item.Drawing);
                    var angles = result.HullAngles;
                    var engine = new FillLinear(workArea, partSpacing);
                    engine.RemainderPatterns = remainderPatterns;
                    var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea);

                    if (filled != null && filled.Count > 0)
                    {
                        var score = FillScore.Compute(filled, workArea);
                        if (best == null || score > bestScore)
                        {
                            best = filled;
                            bestScore = score;
                        }
                    }

                    NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
                        $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
                }
            }
            catch (OperationCanceledException)
            {
                Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
            }

            Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1} ({deadline.ElapsedMilliseconds}ms)");
            return best ?? new List<Part>();
        }

        private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
        {
            var kept = bestFits.Where(r => r.Keep).ToList();
            var top = kept.Take(50).ToList();

            var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
            var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);

            if (workShortSide < plateShortSide * 0.5)
            {
                var stripCandidates = bestFits
                    .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
                             && r.Utilization >= 0.3)
                    .OrderByDescending(r => r.Utilization);

                var existing = new HashSet<BestFitResult>(top);

                foreach (var r in stripCandidates)
                {
                    if (top.Count >= 100)
                        break;

                    if (existing.Add(r))
                        top.Add(r);
                }

                Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
            }

            return top;
        }

        private List<Pattern> BuildRemainderPatterns(List<BestFitResult> candidates, NestItem item)
        {
            var patterns = new List<Pattern>();

            foreach (var candidate in candidates.Take(5))
            {
                var pairParts = candidate.BuildParts(item.Drawing);
                var angles = candidate.HullAngles ?? new List<double> { 0 };

                foreach (var angle in angles.Take(3))
                {
                    var pattern = DefaultNestEngine.BuildRotatedPattern(pairParts, angle);
                    if (pattern.Parts.Count > 0)
                        patterns.Add(pattern);
                }
            }

            return patterns;
        }
    }
}

Note: NestEngineBase.ReportProgress is currently protected static. It needs to become internal static so PairFiller can call it. Change the access modifier in NestEngineBase.cs line 180 from protected static to internal static.

  • Step 4: Change ReportProgress to internal static

In NestEngineBase.cs line 180, change:

  • From: protected static void ReportProgress(

  • To: internal static void ReportProgress(

  • Step 5: Run test to verify it passes

Run: dotnet test OpenNest.Tests --filter "PairFillerTests" -v n Expected: All 3 tests PASS.

  • Step 6: Commit
git add OpenNest.Engine/PairFiller.cs OpenNest.Tests/PairFillerTests.cs OpenNest.Engine/NestEngineBase.cs
git commit -m "refactor(engine): extract PairFiller from DefaultNestEngine"

Chunk 3: RemnantFiller and Engine Rewiring

Task 6: Extract RemnantFiller

Files:

  • Create: OpenNest.Engine/RemnantFiller.cs

  • Create: OpenNest.Tests/RemnantFillerTests2.cs (using 2 to avoid conflict with existing RemnantFinderTests.cs)

  • Step 1: Write the failing tests

In OpenNest.Tests/RemnantFillerTests2.cs:

using OpenNest.Geometry;

namespace OpenNest.Tests;

public class RemnantFillerTests2
{
    private static Drawing MakeSquareDrawing(double size)
    {
        var pgm = new OpenNest.CNC.Program();
        pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
        return new Drawing("sq", pgm);
    }

    [Fact]
    public void FillItems_PlacesPartsInRemnants()
    {
        var workArea = new Box(0, 0, 100, 100);
        var filler = new RemnantFiller(workArea, 1.0);

        // Place a large obstacle leaving a 40x100 strip on the right
        filler.AddObstacles(new[] { TestHelpers.MakePartAt(0, 0, 50) });

        var drawing = MakeSquareDrawing(10);
        var items = new List<NestItem>
        {
            new NestItem { Drawing = drawing, Quantity = 5 }
        };

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        {
            var plate = new Plate(b.Width, b.Length);
            var engine = new DefaultNestEngine(plate);
            return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
        };

        var placed = filler.FillItems(items, fillFunc);

        Assert.True(placed.Count > 0, "Should place parts in remaining space");
    }

    [Fact]
    public void FillItems_DoesNotMutateItemQuantities()
    {
        var workArea = new Box(0, 0, 100, 100);
        var filler = new RemnantFiller(workArea, 1.0);

        var drawing = MakeSquareDrawing(10);
        var items = new List<NestItem>
        {
            new NestItem { Drawing = drawing, Quantity = 3 }
        };

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        {
            var plate = new Plate(b.Width, b.Length);
            var engine = new DefaultNestEngine(plate);
            return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
        };

        filler.FillItems(items, fillFunc);

        Assert.Equal(3, items[0].Quantity);
    }

    [Fact]
    public void FillItems_EmptyItems_ReturnsEmpty()
    {
        var workArea = new Box(0, 0, 100, 100);
        var filler = new RemnantFiller(workArea, 1.0);

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();

        var result = filler.FillItems(new List<NestItem>(), fillFunc);

        Assert.Empty(result);
    }

    [Fact]
    public void FillItems_RespectsCancellation()
    {
        var cts = new System.Threading.CancellationTokenSource();
        cts.Cancel();

        var workArea = new Box(0, 0, 100, 100);
        var filler = new RemnantFiller(workArea, 1.0);

        var drawing = MakeSquareDrawing(10);
        var items = new List<NestItem>
        {
            new NestItem { Drawing = drawing, Quantity = 5 }
        };

        Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
            new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };

        var result = filler.FillItems(items, fillFunc, cts.Token);

        // Should not throw, returns whatever was placed
        Assert.NotNull(result);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n Expected: Build error — RemnantFiller class (the new one, not RemnantFinder) does not exist.

  • Step 3: Create RemnantFiller class

In OpenNest.Engine/RemnantFiller.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;

namespace OpenNest
{
    /// <summary>
    /// Iteratively fills remnant boxes with items using a RemnantFinder.
    /// After each fill, re-discovers free rectangles and tries again
    /// until no more items can be placed.
    /// </summary>
    public class RemnantFiller
    {
        private readonly RemnantFinder finder;
        private readonly double spacing;

        public RemnantFiller(Box workArea, double spacing)
        {
            this.spacing = spacing;
            finder = new RemnantFinder(workArea);
        }

        public void AddObstacles(IEnumerable<Part> parts)
        {
            foreach (var part in parts)
                finder.AddObstacle(part.BoundingBox.Offset(spacing));
        }

        public List<Part> FillItems(
            List<NestItem> items,
            Func<NestItem, Box, List<Part>> fillFunc,
            CancellationToken token = default,
            IProgress<NestProgress> progress = null)
        {
            if (items == null || items.Count == 0)
                return new List<Part>();

            var allParts = new List<Part>();
            var madeProgress = true;

            // Track quantities locally — do not mutate the input NestItem objects.
            // NOTE: Keyed by Drawing.Name — duplicate names would collide.
            // This matches the original StripNestEngine behavior.
            var localQty = new Dictionary<string, int>();
            foreach (var item in items)
                localQty[item.Drawing.Name] = item.Quantity;

            while (madeProgress && !token.IsCancellationRequested)
            {
                madeProgress = false;

                var minRemnantDim = double.MaxValue;
                foreach (var item in items)
                {
                    var qty = localQty[item.Drawing.Name];
                    if (qty <= 0)
                        continue;
                    var bb = item.Drawing.Program.BoundingBox();
                    var dim = System.Math.Min(bb.Width, bb.Length);
                    if (dim < minRemnantDim)
                        minRemnantDim = dim;
                }

                if (minRemnantDim == double.MaxValue)
                    break;

                var freeBoxes = finder.FindRemnants(minRemnantDim);

                if (freeBoxes.Count == 0)
                    break;

                foreach (var item in items)
                {
                    if (token.IsCancellationRequested)
                        break;

                    var qty = localQty[item.Drawing.Name];
                    if (qty == 0)
                        continue;

                    var itemBbox = item.Drawing.Program.BoundingBox();
                    var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);

                    foreach (var box in freeBoxes)
                    {
                        if (System.Math.Min(box.Width, box.Length) < minItemDim)
                            continue;

                        var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
                        var remnantParts = fillFunc(fillItem, box);

                        if (remnantParts != null && remnantParts.Count > 0)
                        {
                            allParts.AddRange(remnantParts);
                            localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);

                            foreach (var p in remnantParts)
                                finder.AddObstacle(p.BoundingBox.Offset(spacing));

                            madeProgress = true;
                            break;
                        }
                    }

                    if (madeProgress)
                        break;
                }
            }

            return allParts;
        }
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n Expected: All 4 tests PASS.

  • Step 5: Commit
git add OpenNest.Engine/RemnantFiller.cs OpenNest.Tests/RemnantFillerTests2.cs
git commit -m "refactor(engine): extract RemnantFiller for iterative remnant filling"

Task 7: Rewire DefaultNestEngine

Files:

  • Modify: OpenNest.Engine/DefaultNestEngine.cs

This is the core rewiring. Replace extracted methods with calls to the new helper classes.

  • Step 1: Add AngleCandidateBuilder field and ForceFullAngleSweep forwarding property

In DefaultNestEngine.cs, replace:

public bool ForceFullAngleSweep { get; set; }

// Angles that have produced results across multiple Fill calls.
// Populated after each Fill; used to prune subsequent fills.
private readonly HashSet<double> knownGoodAngles = new();

With:

private readonly AngleCandidateBuilder angleBuilder = new();

public bool ForceFullAngleSweep
{
    get => angleBuilder.ForceFullSweep;
    set => angleBuilder.ForceFullSweep = value;
}
  • Step 2: Replace BuildCandidateAngles call in FindBestFill

In FindBestFill (line ~181), replace:

var angles = BuildCandidateAngles(item, bestRotation, workArea);

With:

var angles = angleBuilder.Build(item, bestRotation, workArea);
  • Step 3: Replace knownGoodAngles recording in FindBestFill

In FindBestFill, replace the block (around line 243):

// Record productive angles for future fills.
foreach (var ar in AngleResults)
{
    if (ar.PartCount > 0)
        knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
}

With:

angleBuilder.RecordProductive(AngleResults);
  • Step 4: Replace FillWithPairs call in FindBestFill

In FindBestFill, replace:

var pairResult = FillWithPairs(item, workArea, token, progress);

With:

var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress);
  • Step 5: Replace FillWithPairs call in Fill(List) overload

In the Fill(List<Part> groupParts, Box workArea, ...) method (around line 137), replace:

var pairResult = FillWithPairs(nestItem, workArea, token, progress);

With:

var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
var pairResult = pairFiller.Fill(nestItem, workArea, PlateNumber, token, progress);
  • Step 6: Delete the extracted methods

Remove these methods and fields from DefaultNestEngine:

  • private readonly HashSet<double> knownGoodAngles (already replaced in Step 1)
  • private List<double> BuildCandidateAngles(...) (entire method, lines 279-347)
  • private List<Part> FillWithPairs(...) (entire method, lines 365-421)
  • private List<BestFitResult> SelectPairCandidates(...) (entire method, lines 428-462)
  • private List<Pattern> BuildRemainderPatterns(...) (entire method, lines 471-489)
  • private const int MinPairCandidates = 10; (line 362)
  • private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3); (line 363)

Also remove now-unused using statements if any (e.g., using OpenNest.Engine.BestFit; if no longer referenced, though QuickFillCount still uses BestFitCache).

  • Step 7: Build to verify

Run: dotnet build OpenNest.sln Expected: Build succeeds with no errors.

  • Step 8: Run all tests

Run: dotnet test OpenNest.Tests -v n Expected: All tests PASS.

  • Step 9: Commit
git add OpenNest.Engine/DefaultNestEngine.cs
git commit -m "refactor(engine): rewire DefaultNestEngine to use extracted helpers"

Task 8: Rewire StripNestEngine

Files:

  • Modify: OpenNest.Engine/StripNestEngine.cs

  • Step 1: Replace TryOrientation shrink loop with ShrinkFiller

In TryOrientation, replace the shrink loop (lines 188-215) with:

// Shrink to tightest strip.
var shrinkAxis = direction == StripDirection.Bottom
    ? ShrinkAxis.Height : ShrinkAxis.Width;

Func<NestItem, Box, List<Part>> shrinkFill = (ni, b) =>
{
    var trialInner = new DefaultNestEngine(Plate);
    return trialInner.Fill(ni, b, progress, token);
};

var shrinkResult = ShrinkFiller.Shrink(shrinkFill,
    new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
    stripBox, Plate.PartSpacing, shrinkAxis, token);

if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0)
    return result;

bestParts = shrinkResult.Parts;
bestDim = shrinkResult.Dimension;

Remove the local variables that the shrink loop used to compute (actualDim, targetCount, and the for loop itself). Keep the remnant section below it.

Important: The initial fill call (lines 169-172) stays as-is — ShrinkFiller handles both the initial fill AND the shrink loop. So replace from the initial fill through the shrink loop with the single ShrinkFiller.Shrink() call, since Shrink does its own initial fill internally.

Actually — looking more carefully, the initial fill (lines 170-172) uses progress while shrink trials in the original code create a new DefaultNestEngine each time. ShrinkFiller.Shrink does its own initial fill via fillFunc. So the replacement should:

  1. Remove the initial fill (lines 169-175) and the shrink loop (lines 188-215)
  2. Replace both with the single ShrinkFiller.Shrink() call

The fillFunc delegate handles both the initial fill and each shrink trial.

  • Step 2: Replace ShrinkFill method with ShrinkFiller calls

Replace the entire ShrinkFill method (lines 358-419) with:

private List<Part> ShrinkFill(NestItem item, Box box,
    IProgress<NestProgress> progress, CancellationToken token)
{
    Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
    {
        var inner = new DefaultNestEngine(Plate);
        return inner.Fill(ni, b, null, token);
    };

    // The original code shrinks width then height independently against the
    // original box. The height shrink's result overwrites the width shrink's,
    // so only the height result matters. We run both to preserve behavior
    // (width shrink is a no-op on the final result but we keep it for parity).
    var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
        Plate.PartSpacing, ShrinkAxis.Height, token);

    return heightResult.Parts;
}

Note: The original ShrinkFill runs width-shrink then height-shrink sequentially, but the height result always overwrites the width result (both shrink against the original box independently, targetCount never changes). Running only height-shrink is faithful to the original output while avoiding redundant initial fills.

  • Step 3: Replace remnant loop with RemnantFiller

In TryOrientation, replace the remnant section (from // Build remnant box with spacing gap through the end of the while (madeProgress ...) loop) with:

// Fill remnants
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
    var remnantProgress = progress != null
        ? new AccumulatingProgress(progress, allParts)
        : (IProgress<NestProgress>)null;

    var remnantFiller = new RemnantFiller(workArea, spacing);
    remnantFiller.AddObstacles(allParts);

    Func<NestItem, Box, List<Part>> remnantFillFunc = (ni, b) =>
        ShrinkFill(ni, b, remnantProgress, token);

    var additional = remnantFiller.FillItems(effectiveRemainder,
        remnantFillFunc, token, remnantProgress);

    allParts.AddRange(additional);
}

Keep the effectiveRemainder construction and sorting that precedes this block (lines 239-259). Keep the remnantBox computation (lines 228-233) since we need to check remnantBox.Width > 0.

Remove:

  • The localQty dictionary (lines 276-278)

  • The while (madeProgress ...) loop (lines 280-342)

  • The AccumulatingProgress nested class (lines 425-449) — now using the standalone version

  • Step 4: Build to verify

Run: dotnet build OpenNest.sln Expected: Build succeeds.

  • Step 5: Run all tests

Run: dotnet test OpenNest.Tests -v n Expected: All tests PASS.

  • Step 6: Commit
git add OpenNest.Engine/StripNestEngine.cs
git commit -m "refactor(engine): rewire StripNestEngine to use extracted helpers"

Task 9: Rewire NestEngineBase.Nest

Files:

  • Modify: OpenNest.Engine/NestEngineBase.cs:74-97

  • Step 1: Replace the fill loop with RemnantFiller

In NestEngineBase.Nest, replace phase 1 (the foreach (var item in fillItems) loop, lines 74-98) with:

// Phase 1: Fill multi-quantity drawings using RemnantFiller.
if (fillItems.Count > 0)
{
    var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing);

    Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
        FillExact(ni, b, progress, token);

    var fillParts = remnantFiller.FillItems(fillItems, fillFunc, token, progress);

    if (fillParts.Count > 0)
    {
        allParts.AddRange(fillParts);

        // Deduct placed quantities
        foreach (var item in fillItems)
        {
            var placed = fillParts.Count(p =>
                p.BaseDrawing.Name == item.Drawing.Name);
            item.Quantity = System.Math.Max(0, item.Quantity - placed);
        }

        // Update workArea for pack phase
        var placedObstacles = fillParts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList();
        var finder = new RemnantFinder(workArea, placedObstacles);
        var remnants = finder.FindRemnants();
        if (remnants.Count > 0)
            workArea = remnants[0];
        else
            workArea = new Box(0, 0, 0, 0);
    }
}
  • Step 2: Build to verify

Run: dotnet build OpenNest.sln Expected: Build succeeds.

  • Step 3: Run all tests

Run: dotnet test OpenNest.Tests -v n Expected: All tests PASS.

  • Step 4: Commit
git add OpenNest.Engine/NestEngineBase.cs
git commit -m "refactor(engine): use RemnantFiller in NestEngineBase.Nest"

Task 10: Integration Smoke Tests

Files:

  • Create: OpenNest.Tests/EngineRefactorSmokeTests.cs

These tests verify that the refactored engines produce valid results end-to-end.

  • Step 1: Write smoke tests

In OpenNest.Tests/EngineRefactorSmokeTests.cs:

using OpenNest.Geometry;

namespace OpenNest.Tests;

public class EngineRefactorSmokeTests
{
    private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
    {
        var pgm = new OpenNest.CNC.Program();
        pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
        pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
        return new Drawing(name, pgm);
    }

    [Fact]
    public void DefaultEngine_FillNestItem_ProducesResults()
    {
        var plate = new Plate(120, 60);
        var engine = new DefaultNestEngine(plate);
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };

        var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);

        Assert.True(parts.Count > 0, "DefaultNestEngine should fill parts");
    }

    [Fact]
    public void DefaultEngine_FillGroupParts_ProducesResults()
    {
        var plate = new Plate(120, 60);
        var engine = new DefaultNestEngine(plate);
        var drawing = MakeRectDrawing(20, 10);
        var groupParts = new List<Part> { new Part(drawing) };

        var parts = engine.Fill(groupParts, plate.WorkArea(), null, System.Threading.CancellationToken.None);

        Assert.True(parts.Count > 0, "DefaultNestEngine group fill should produce parts");
    }

    [Fact]
    public void DefaultEngine_ForceFullAngleSweep_StillWorks()
    {
        var plate = new Plate(120, 60);
        var engine = new DefaultNestEngine(plate);
        engine.ForceFullAngleSweep = true;
        var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };

        var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);

        Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results");
    }

    [Fact]
    public void StripEngine_Nest_ProducesResults()
    {
        var plate = new Plate(120, 60);
        var engine = new StripNestEngine(plate);
        var items = new List<NestItem>
        {
            new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 10 },
            new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
        };

        var parts = engine.Nest(items, null, System.Threading.CancellationToken.None);

        Assert.True(parts.Count > 0, "StripNestEngine should nest parts");
    }

    [Fact]
    public void DefaultEngine_Nest_ProducesResults()
    {
        var plate = new Plate(120, 60);
        var engine = new DefaultNestEngine(plate);
        var items = new List<NestItem>
        {
            new NestItem { Drawing = MakeRectDrawing(20, 10, "a"), Quantity = 5 },
            new NestItem { Drawing = MakeRectDrawing(15, 8, "b"), Quantity = 3 },
        };

        var parts = engine.Nest(items, null, System.Threading.CancellationToken.None);

        Assert.True(parts.Count > 0, "Base Nest method should place parts");
    }

    [Fact]
    public void BruteForceRunner_StillWorks()
    {
        var plate = new Plate(120, 60);
        var drawing = MakeRectDrawing(20, 10);

        var result = OpenNest.Engine.ML.BruteForceRunner.Run(drawing, plate, forceFullAngleSweep: true);

        Assert.NotNull(result);
        Assert.True(result.PartCount > 0);
    }
}
  • Step 2: Run all smoke tests

Run: dotnet test OpenNest.Tests --filter "EngineRefactorSmokeTests" -v n Expected: All 6 tests PASS.

  • Step 3: Run the full test suite

Run: dotnet test OpenNest.Tests -v n Expected: All tests PASS.

  • Step 4: Commit
git add OpenNest.Tests/EngineRefactorSmokeTests.cs
git commit -m "test(engine): add integration smoke tests for engine refactor"

Task 11: Clean up unused imports

Files:

  • Modify: OpenNest.Engine/DefaultNestEngine.cs

  • Modify: OpenNest.Engine/StripNestEngine.cs

  • Step 1: Remove unused using statements from DefaultNestEngine

After extracting the pair/angle code, check which using statements are no longer needed. Likely candidates:

  • using OpenNest.Engine.BestFit; — may still be needed by QuickFillCount
  • using OpenNest.Engine.ML; — no longer needed (moved to AngleCandidateBuilder)

Build to verify: dotnet build OpenNest.sln

  • Step 2: Remove AccumulatingProgress nested class from StripNestEngine if still present

Verify StripNestEngine.cs no longer contains the AccumulatingProgress nested class definition. It should have been removed in Task 8.

  • Step 3: Build and run full test suite

Run: dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v n Expected: Build succeeds, all tests PASS.

  • Step 4: Commit
git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/StripNestEngine.cs
git commit -m "refactor(engine): clean up unused imports after extraction"