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

1646 lines
53 KiB
Markdown

# 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>`:
```xml
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
```
- [ ] **Step 2: Build to verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
- [ ] **Step 3: Commit**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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**
```bash
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:
```csharp
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:
```csharp
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:
```csharp
var angles = BuildCandidateAngles(item, bestRotation, workArea);
```
With:
```csharp
var angles = angleBuilder.Build(item, bestRotation, workArea);
```
- [ ] **Step 3: Replace knownGoodAngles recording in FindBestFill**
In `FindBestFill`, replace the block (around line 243):
```csharp
// Record productive angles for future fills.
foreach (var ar in AngleResults)
{
if (ar.PartCount > 0)
knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
}
```
With:
```csharp
angleBuilder.RecordProductive(AngleResults);
```
- [ ] **Step 4: Replace FillWithPairs call in FindBestFill**
In `FindBestFill`, replace:
```csharp
var pairResult = FillWithPairs(item, workArea, token, progress);
```
With:
```csharp
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<Part>) overload**
In the `Fill(List<Part> groupParts, Box workArea, ...)` method (around line 137), replace:
```csharp
var pairResult = FillWithPairs(nestItem, workArea, token, progress);
```
With:
```csharp
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**
```bash
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:
```csharp
// 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:
```csharp
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:
```csharp
// 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**
```bash
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:
```csharp
// 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**
```bash
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`:
```csharp
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**
```bash
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**
```bash
git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/StripNestEngine.cs
git commit -m "refactor(engine): clean up unused imports after extraction"
```