From 4c4e8c37fb81de758075cf04ba6103478198c3ab Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 22:05:51 -0400 Subject: [PATCH] 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) --- .../plans/2026-03-16-engine-refactor.md | 1645 +++++++++++++++++ 1 file changed, 1645 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-engine-refactor.md diff --git a/docs/superpowers/plans/2026-03-16-engine-refactor.md b/docs/superpowers/plans/2026-03-16-engine-refactor.md new file mode 100644 index 0000000..fbf14ca --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-engine-refactor.md @@ -0,0 +1,1645 @@ +# 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 `` or add a new ``: + +```xml + + + +``` + +- [ ] **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 + { + 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 { TestHelpers.MakePartAt(0, 0, 10) }; + var accumulating = new AccumulatingProgress(inner, previous); + + var newParts = new List { 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()); + + var newParts = new List { 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 { 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 +{ + /// + /// Wraps an IProgress to prepend previously placed parts to each report, + /// so the UI shows the full picture during incremental fills. + /// + internal class AccumulatingProgress : IProgress + { + private readonly IProgress inner; + private readonly List previousParts; + + public AccumulatingProgress(IProgress inner, List previousParts) + { + this.inner = inner; + this.previousParts = previousParts; + } + + public void Report(NestProgress value) + { + if (value.BestParts != null && previousParts.Count > 0) + { + var combined = new List(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 `` 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> 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> 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> fillFunc = (ni, b) => + { + callCount++; + return new List { 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> fillFunc = (ni, b) => + new List { 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 Parts { get; set; } + public double Dimension { get; set; } + } + + /// + /// 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. + /// + public static class ShrinkFiller + { + public static ShrinkResult Shrink( + Func> 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(), 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 parts, Box box, ShrinkAxis axis) + { + var placedBox = parts.Cast().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 + { + 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 +{ + /// + /// 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. + /// + public class AngleCandidateBuilder + { + private readonly HashSet knownGoodAngles = new(); + + public bool ForceFullSweep { get; set; } + + public List Build(NestItem item, double bestRotation, Box workArea) + { + var angles = new List { 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(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 { 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; + } + + /// + /// Records angles that produced results. These are used to prune + /// subsequent Build() calls. + /// + public void RecordProductive(List 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 groupParts, double angle)` +- To: `internal static Pattern BuildRotatedPattern(List groupParts, double angle)` + +Change `FillPattern` (line 513): +- From: `private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)` +- To: `internal static List FillPattern(FillLinear engine, List groupParts, List 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 +{ + /// + /// Fills a work area using interlocking part pairs from BestFitCache. + /// Extracted from DefaultNestEngine.FillWithPairs. + /// + 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 Fill(NestItem item, Box workArea, + int plateNumber = 0, + CancellationToken token = default, + IProgress 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 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(); + } + + private List SelectPairCandidates(List 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(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 BuildRemainderPatterns(List candidates, NestItem item) + { + var patterns = new List(); + + foreach (var candidate in candidates.Take(5)) + { + var pairParts = candidate.BuildParts(item.Drawing); + var angles = candidate.HullAngles ?? new List { 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 + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> 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 + { + new NestItem { Drawing = drawing, Quantity = 3 } + }; + + Func> 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> fillFunc = (ni, b) => new List(); + + var result = filler.FillItems(new List(), 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 + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + new List { 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 +{ + /// + /// 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. + /// + 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 parts) + { + foreach (var part in parts) + finder.AddObstacle(part.BoundingBox.Offset(spacing)); + } + + public List FillItems( + List items, + Func> fillFunc, + CancellationToken token = default, + IProgress progress = null) + { + if (items == null || items.Count == 0) + return new List(); + + var allParts = new List(); + 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(); + 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 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) overload** + +In the `Fill(List 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 knownGoodAngles` (already replaced in Step 1) +- `private List BuildCandidateAngles(...)` (entire method, lines 279-347) +- `private List FillWithPairs(...)` (entire method, lines 365-421) +- `private List SelectPairCandidates(...)` (entire method, lines 428-462) +- `private List 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> 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 ShrinkFill(NestItem item, Box box, + IProgress progress, CancellationToken token) +{ + Func> 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)null; + + var remnantFiller = new RemnantFiller(workArea, spacing); + remnantFiller.AddObstacles(allParts); + + Func> 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> 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 { 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 + { + 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 + { + 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" +```