Compare commits

...

7 Commits

Author SHA1 Message Date
aj 3756ea255e fix(test): plate size 2026-03-20 00:32:45 -04:00
aj 33ba40e203 refactor: use TrimToCount instead of blind Take(N) in DefaultNestEngine.Fill 2026-03-20 00:09:53 -04:00
aj 6d66636e3d refactor: replace ShrinkFiller shrink loop with TrimToCount
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:06:58 -04:00
aj 85278bbb75 feat: add ShrinkFiller.TrimToCount for axis-aware edge trimming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 00:03:57 -04:00
aj f0a3547bd1 docs: add trim-to-count implementation plan
Three tasks: add TrimToCount with tests, replace shrink loop, replace
Take(N) in DefaultNestEngine.Fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:01:39 -04:00
aj fe2a293128 docs: address spec review feedback for trim-to-count
Clarify sort direction (ascending, keep nearest to origin), document
parameter changes, MeasureDimension behavior, and behavioral trade-off.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:56:14 -04:00
aj 11f605801f docs: add trim-to-count design spec
Replace expensive ShrinkFiller re-fill loop with axis-aware edge-sorted
trim. Also replaces blind Take(N) in DefaultNestEngine.Fill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:53:35 -04:00
7 changed files with 520 additions and 72 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ namespace OpenNest
var best = context.CurrentBest ?? new List<Part>();
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
isOverallBest: true);
+22 -42
View File
@@ -16,9 +16,8 @@ namespace OpenNest.Engine.Fill
}
/// <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 target number of parts.
/// Fills a box and trims excess parts by removing those farthest from
/// the origin along the shrink axis.
/// </summary>
public static class ShrinkFiller
{
@@ -28,22 +27,17 @@ namespace OpenNest.Engine.Fill
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int maxIterations = 20,
int targetCount = 0,
IProgress<NestProgress> progress = null,
int plateNumber = 0,
List<Part> placedParts = null)
{
// If a target count is specified, estimate a smaller starting box
// to avoid an expensive full-area fill.
var startBox = box;
if (targetCount > 0)
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
var parts = fillFunc(item, startBox);
// If estimate was too aggressive and we got fewer than target,
// fall back to the full box.
if (targetCount > 0 && startBox != box
&& (parts == null || parts.Count < targetCount))
{
@@ -53,47 +47,18 @@ namespace OpenNest.Engine.Fill
if (parts == null || parts.Count == 0)
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
// Shrink target: if a target count was given and we got at least that many,
// shrink to fit targetCount (not the full count). This produces a tighter box.
// If we got fewer than target, shrink to maintain what we have.
var shrinkTarget = targetCount > 0
? System.Math.Min(targetCount, parts.Count)
: parts.Count;
var bestParts = parts;
var bestDim = MeasureDimension(parts, box, axis);
if (parts.Count > shrinkTarget)
parts = TrimToCount(parts, shrinkTarget, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
var dim = MeasureDimension(parts, box, axis);
for (var i = 0; i < maxIterations; i++)
{
if (token.IsCancellationRequested)
break;
ReportShrinkProgress(progress, plateNumber, placedParts, parts, box, axis, dim);
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);
// Report the trial box before the fill so the UI updates the
// work area border immediately rather than after the fill completes.
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, trialDim);
var trialParts = fillFunc(item, trialBox);
if (trialParts == null || trialParts.Count < shrinkTarget)
break;
bestParts = trialParts;
bestDim = MeasureDimension(trialParts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
}
return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
return new ShrinkResult { Parts = parts, Dimension = dim };
}
private static void ReportShrinkProgress(
@@ -163,5 +128,20 @@ namespace OpenNest.Engine.Fill
? placedBox.Right - box.X
: placedBox.Top - box.Y;
}
/// <summary>
/// Keeps the <paramref name="targetCount"/> parts nearest to the origin
/// along the given axis, discarding parts farthest from the origin.
/// Returns the input list unchanged if count is already at or below target.
/// </summary>
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
{
if (parts == null || parts.Count <= targetCount)
return parts;
return axis == ShrinkAxis.Width
? parts.OrderBy(p => p.BoundingBox.Right).Take(targetCount).ToList()
: parts.OrderBy(p => p.BoundingBox.Top).Take(targetCount).ToList();
}
}
}
+6 -6
View File
@@ -18,7 +18,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void DefaultEngine_FillNestItem_ProducesResults()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
@@ -30,7 +30,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void DefaultEngine_FillGroupParts_ProducesResults()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var drawing = MakeRectDrawing(20, 10);
var groupParts = new List<Part> { new Part(drawing) };
@@ -43,7 +43,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void DefaultEngine_ForceFullAngleSweep_StillWorks()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
engine.ForceFullAngleSweep = true;
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
@@ -56,7 +56,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void StripEngine_Nest_ProducesResults()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new StripNestEngine(plate);
var items = new List<NestItem>
{
@@ -72,7 +72,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void DefaultEngine_Nest_ProducesResults()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var items = new List<NestItem>
{
@@ -88,7 +88,7 @@ public class EngineRefactorSmokeTests
[Fact]
public void BruteForceRunner_StillWorks()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var drawing = MakeRectDrawing(20, 10);
var result = OpenNest.Engine.ML.BruteForceRunner.Run(drawing, plate, forceFullAngleSweep: true);
+68 -20
View File
@@ -17,7 +17,7 @@ public class ShrinkFillerTests
}
[Fact]
public void Shrink_ReducesDimension_UntilCountDrops()
public void Shrink_FillsAndReturnsDimension()
{
var drawing = MakeSquareDrawing(10);
var item = new NestItem { Drawing = drawing };
@@ -59,25 +59,6 @@ public class ShrinkFillerTests
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()
{
@@ -97,4 +78,71 @@ public class ShrinkFillerTests
Assert.NotNull(result);
Assert.True(result.Parts.Count > 0);
}
[Fact]
public void TrimToCount_Width_KeepsPartsNearestToOrigin()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5), // Right = 5
TestHelpers.MakePartAt(10, 0, 5), // Right = 15
TestHelpers.MakePartAt(20, 0, 5), // Right = 25
TestHelpers.MakePartAt(30, 0, 5), // Right = 35
};
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
Assert.Equal(2, trimmed.Count);
Assert.True(trimmed.All(p => p.BoundingBox.Right <= 15));
}
[Fact]
public void TrimToCount_Height_KeepsPartsNearestToOrigin()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5), // Top = 5
TestHelpers.MakePartAt(0, 10, 5), // Top = 15
TestHelpers.MakePartAt(0, 20, 5), // Top = 25
TestHelpers.MakePartAt(0, 30, 5), // Top = 35
};
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Height);
Assert.Equal(2, trimmed.Count);
Assert.True(trimmed.All(p => p.BoundingBox.Top <= 15));
}
[Fact]
public void TrimToCount_ReturnsInput_WhenCountAtOrBelowTarget()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var same = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
Assert.Same(parts, same);
var fewer = ShrinkFiller.TrimToCount(parts, 5, ShrinkAxis.Width);
Assert.Same(parts, fewer);
}
[Fact]
public void TrimToCount_DoesNotMutateInput()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
TestHelpers.MakePartAt(20, 0, 5),
};
var originalCount = parts.Count;
var trimmed = ShrinkFiller.TrimToCount(parts, 1, ShrinkAxis.Width);
Assert.Equal(originalCount, parts.Count);
Assert.Equal(1, trimmed.Count);
}
}
@@ -18,7 +18,7 @@ public class FillPipelineTests
[Fact]
public void Pipeline_PopulatesPhaseResults()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
@@ -31,7 +31,7 @@ public class FillPipelineTests
[Fact]
public void Pipeline_SetsWinnerPhase()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
@@ -47,7 +47,7 @@ public class FillPipelineTests
[Fact]
public void Pipeline_RespectsCancellation()
{
var plate = new Plate(120, 60);
var plate = new Plate(60, 120);
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var cts = new System.Threading.CancellationTokenSource();
@@ -0,0 +1,354 @@
# Trim-to-Count Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the expensive iterative ShrinkFiller loop with a single fill + axis-aware edge-sorted trim, and replace the blind `Take(N)` in DefaultNestEngine.Fill with the same trim.
**Architecture:** Add `ShrinkFiller.TrimToCount` static method that sorts parts by trailing edge and keeps the N nearest to origin. Gut the shrink loop in `Shrink`, remove `maxIterations` parameter. Update `DefaultNestEngine.Fill` to call `TrimToCount` instead of `Take(N)`.
**Tech Stack:** .NET 8, xUnit
**Spec:** `docs/superpowers/specs/2026-03-19-trim-to-count-design.md`
---
## File Map
- **Modify:** `OpenNest.Engine/Fill/ShrinkFiller.cs` — add `TrimToCount`, replace shrink loop, remove `maxIterations`
- **Modify:** `OpenNest.Engine/DefaultNestEngine.cs:55-56` — replace `Take(N)` with `TrimToCount`
- **Modify:** `OpenNest.Tests/ShrinkFillerTests.cs` — delete `Shrink_RespectsMaxIterations`, update existing tests, add `TrimToCount` tests
---
### Task 1: Add TrimToCount with tests
**Files:**
- Modify: `OpenNest.Tests/ShrinkFillerTests.cs`
- Modify: `OpenNest.Engine/Fill/ShrinkFiller.cs`
- [ ] **Step 1: Write failing tests for TrimToCount**
Add these tests to the bottom of `OpenNest.Tests/ShrinkFillerTests.cs`:
```csharp
[Fact]
public void TrimToCount_Width_KeepsPartsNearestToOrigin()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5), // Right = 5
TestHelpers.MakePartAt(10, 0, 5), // Right = 15
TestHelpers.MakePartAt(20, 0, 5), // Right = 25
TestHelpers.MakePartAt(30, 0, 5), // Right = 35
};
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
Assert.Equal(2, trimmed.Count);
Assert.True(trimmed.All(p => p.BoundingBox.Right <= 15));
}
[Fact]
public void TrimToCount_Height_KeepsPartsNearestToOrigin()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5), // Top = 5
TestHelpers.MakePartAt(0, 10, 5), // Top = 15
TestHelpers.MakePartAt(0, 20, 5), // Top = 25
TestHelpers.MakePartAt(0, 30, 5), // Top = 35
};
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Height);
Assert.Equal(2, trimmed.Count);
Assert.True(trimmed.All(p => p.BoundingBox.Top <= 15));
}
[Fact]
public void TrimToCount_ReturnsInput_WhenCountAtOrBelowTarget()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
};
var same = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
Assert.Same(parts, same);
var fewer = ShrinkFiller.TrimToCount(parts, 5, ShrinkAxis.Width);
Assert.Same(parts, fewer);
}
[Fact]
public void TrimToCount_DoesNotMutateInput()
{
var parts = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 5),
TestHelpers.MakePartAt(10, 0, 5),
TestHelpers.MakePartAt(20, 0, 5),
};
var originalCount = parts.Count;
var trimmed = ShrinkFiller.TrimToCount(parts, 1, ShrinkAxis.Width);
Assert.Equal(originalCount, parts.Count);
Assert.Equal(1, trimmed.Count);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test OpenNest.Tests --filter "TrimToCount"`
Expected: Build error — `ShrinkFiller` does not contain `TrimToCount`
- [ ] **Step 3: Implement TrimToCount**
Add this method to `OpenNest.Engine/Fill/ShrinkFiller.cs` inside the `ShrinkFiller` class, after the `Shrink` method. Note: `internal` visibility works because `OpenNest.Engine.csproj` has `<InternalsVisibleTo Include="OpenNest.Tests" />`.
```csharp
/// <summary>
/// Keeps the <paramref name="targetCount"/> parts nearest to the origin
/// along the given axis, discarding parts farthest from the origin.
/// Returns the input list unchanged if count is already at or below target.
/// </summary>
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
{
if (parts == null || parts.Count <= targetCount)
return parts;
return axis == ShrinkAxis.Width
? parts.OrderBy(p => p.BoundingBox.Right).Take(targetCount).ToList()
: parts.OrderBy(p => p.BoundingBox.Top).Take(targetCount).ToList();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test OpenNest.Tests --filter "TrimToCount"`
Expected: All 4 TrimToCount tests PASS
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/Fill/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
git commit -m "feat: add ShrinkFiller.TrimToCount for axis-aware edge trimming"
```
---
### Task 2: Replace ShrinkFiller shrink loop with TrimToCount
**Files:**
- Modify: `OpenNest.Engine/Fill/ShrinkFiller.cs`
- Modify: `OpenNest.Tests/ShrinkFillerTests.cs`
- [ ] **Step 1: Delete `Shrink_RespectsMaxIterations` test and rename misleading test**
Remove the entire `Shrink_RespectsMaxIterations` test method (lines 63-79) from `OpenNest.Tests/ShrinkFillerTests.cs`.
Rename `Shrink_ReducesDimension_UntilCountDrops` to `Shrink_FillsAndReturnsDimension` since the iterative shrink behavior no longer exists.
- [ ] **Step 2: Replace the shrink loop in `Shrink`**
In `OpenNest.Engine/Fill/ShrinkFiller.cs`, replace the `Shrink` method signature and body. Remove the `maxIterations` parameter. Replace the shrink loop (lines 63-96) with trim + measure:
Before (lines 25-97):
```csharp
public static ShrinkResult Shrink(
Func<NestItem, Box, List<Part>> fillFunc,
NestItem item, Box box,
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int maxIterations = 20,
int targetCount = 0,
IProgress<NestProgress> progress = null,
int plateNumber = 0,
List<Part> placedParts = null)
{
// If a target count is specified, estimate a smaller starting box
// to avoid an expensive full-area fill.
var startBox = box;
if (targetCount > 0)
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
var parts = fillFunc(item, startBox);
// If estimate was too aggressive and we got fewer than target,
// fall back to the full box.
if (targetCount > 0 && startBox != box
&& (parts == null || parts.Count < targetCount))
{
parts = fillFunc(item, box);
}
if (parts == null || parts.Count == 0)
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
// Shrink target: if a target count was given and we got at least that many,
// shrink to fit targetCount (not the full count). This produces a tighter box.
// If we got fewer than target, shrink to maintain what we have.
var shrinkTarget = targetCount > 0
? System.Math.Min(targetCount, parts.Count)
: parts.Count;
var bestParts = parts;
var bestDim = MeasureDimension(parts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
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);
// Report the trial box before the fill so the UI updates the
// work area border immediately rather than after the fill completes.
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, trialDim);
var trialParts = fillFunc(item, trialBox);
if (trialParts == null || trialParts.Count < shrinkTarget)
break;
bestParts = trialParts;
bestDim = MeasureDimension(trialParts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
}
return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
}
```
After:
```csharp
public static ShrinkResult Shrink(
Func<NestItem, Box, List<Part>> fillFunc,
NestItem item, Box box,
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int targetCount = 0,
IProgress<NestProgress> progress = null,
int plateNumber = 0,
List<Part> placedParts = null)
{
var startBox = box;
if (targetCount > 0)
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
var parts = fillFunc(item, startBox);
if (targetCount > 0 && startBox != box
&& (parts == null || parts.Count < targetCount))
{
parts = fillFunc(item, box);
}
if (parts == null || parts.Count == 0)
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
var shrinkTarget = targetCount > 0
? System.Math.Min(targetCount, parts.Count)
: parts.Count;
if (parts.Count > shrinkTarget)
parts = TrimToCount(parts, shrinkTarget, axis);
var dim = MeasureDimension(parts, box, axis);
ReportShrinkProgress(progress, plateNumber, placedParts, parts, box, axis, dim);
return new ShrinkResult { Parts = parts, Dimension = dim };
}
```
- [ ] **Step 3: Update the class xmldoc**
Replace the `ShrinkFiller` class summary (line 18-22):
Before:
```csharp
/// <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 target number of parts.
/// </summary>
```
After:
```csharp
/// <summary>
/// Fills a box and trims excess parts by removing those farthest from
/// the origin along the shrink axis.
/// </summary>
```
- [ ] **Step 4: Run all ShrinkFiller tests**
Run: `dotnet test OpenNest.Tests --filter "ShrinkFiller"`
Expected: All tests PASS (the `Shrink_RespectsMaxIterations` test was deleted, remaining tests still pass because the fill + trim produces valid results with positive counts and dimensions)
- [ ] **Step 5: Build the full solution to check for compilation errors**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds. No callers pass `maxIterations` by name except the deleted test. `IterativeShrinkFiller.cs` uses positional args and does not pass `maxIterations`.
- [ ] **Step 6: Commit**
```bash
git add OpenNest.Engine/Fill/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
git commit -m "refactor: replace ShrinkFiller shrink loop with TrimToCount"
```
---
### Task 3: Replace Take(N) in DefaultNestEngine.Fill
**Files:**
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:55-56`
- [ ] **Step 1: Replace the Take(N) call**
In `OpenNest.Engine/DefaultNestEngine.cs`, replace lines 55-56:
Before:
```csharp
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
```
After:
```csharp
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
```
- [ ] **Step 2: Build to verify compilation**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds. `ShrinkFiller` and `ShrinkAxis` are already available via the `using OpenNest.Engine.Fill;` import on line 1.
- [ ] **Step 3: Run all engine tests**
Run: `dotnet test OpenNest.Tests`
Expected: All tests PASS
- [ ] **Step 4: Commit**
```bash
git add OpenNest.Engine/DefaultNestEngine.cs
git commit -m "refactor: use TrimToCount instead of blind Take(N) in DefaultNestEngine.Fill"
```
@@ -0,0 +1,66 @@
# Trim-to-Count: Replace ShrinkFiller Loop with Edge-Sorted Trim
## Problem
When a fill produces more parts than needed, `ShrinkFiller` iteratively shrinks the work area and re-fills from scratch until the count drops below target. Each iteration runs the full fill pipeline (pairs, bestfit, linear), making this expensive. Meanwhile, `DefaultNestEngine.Fill` trims excess parts with a blind `Take(N)` that ignores spatial position.
## Solution
Add `ShrinkFiller.TrimToCount` — a static method that sorts parts by their trailing edge and removes from the far end until the target count is reached. Replace the shrink loop and the blind `Take(N)` with calls to this method.
## Design
### New method: `ShrinkFiller.TrimToCount`
```csharp
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
```
- Returns input unchanged if `parts.Count <= targetCount`
- Sorts ascending by trailing edge, takes the first `targetCount` parts (keeps parts nearest to origin, discards farthest):
- `ShrinkAxis.Width` → sort ascending by `BoundingBox.Right`
- `ShrinkAxis.Height` → sort ascending by `BoundingBox.Top`
- Returns a new list (does not mutate input)
### Changes to `ShrinkFiller.Shrink`
Replace the iterative shrink loop:
1. Fill once using existing `EstimateStartBox` + fallback logic (unchanged)
2. If count exceeds `shrinkTarget`, call `TrimToCount(parts, shrinkTarget, axis)`
3. Measure dimension from trimmed result via existing `MeasureDimension`
4. Report progress once after trim
5. Return `ShrinkResult`
Parameters removed from `Shrink`: `maxIterations` (no loop). The `spacing` parameter is kept (used by `EstimateStartBox`). `CancellationToken` is kept in the signature for API consistency even though the loop no longer uses it.
### Changes to `DefaultNestEngine.Fill`
Replace line 55-56:
```csharp
// Before:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
// After:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
```
Defaults to `ShrinkAxis.Width` (trim by right edge) since this is the natural "end of nest" direction outside of a shrink context.
## Design Decisions
- **Axis-aware trimming**: Height shrink trims by top edge, width shrink trims by right edge. This respects the strip direction.
- **No pair integrity**: Trimming may split interlocking pairs. This is acceptable because if the layout is suboptimal, a better candidate will replace it during evaluation.
- **No edge spacing concerns**: The new dimension is simply the max edge of remaining parts. No snapping to spacing increments.
- **`MeasureDimension` unchanged**: It measures the occupied extent of remaining parts relative to `box.X`/`box.Y` (the work area origin). This works correctly after trimming.
- **`EstimateStartBox` preserved**: It was designed to accelerate the iterative loop, which is now gone. It still helps by producing a smaller starting fill, but could be simplified in a future pass.
- **Behavioral trade-off**: The shrink loop found the smallest box fitting N parts; trim-to-count reports the actual extent of the N nearest parts, which may be slightly less tight if there are gaps. In practice this is negligible since fill algorithms pack densely.
## Files Changed
- `OpenNest.Engine/Fill/ShrinkFiller.cs` — add `TrimToCount`, replace shrink loop, remove `maxIterations`
- `OpenNest.Engine/DefaultNestEngine.cs` — replace `Take(N)` with `TrimToCount`
- `OpenNest.Tests/ShrinkFillerTests.cs` — delete `Shrink_RespectsMaxIterations` test (concept no longer exists), update remaining tests, add `TrimToCount` tests