docs: add iterative shrink-fill implementation plan

Includes fix for unlimited qty items (Quantity <= 0) that
RemnantFiller.FillItems silently skips. Workaround: convert
to estimated max capacity before passing in.

Also removes caliper angle sections from spec — RotationAnalysis
already feeds the caliper angle via FindBestRotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 10:26:08 -04:00
parent ed555ba56a
commit 1bc635acde
2 changed files with 628 additions and 36 deletions

View File

@@ -0,0 +1,620 @@
# Iterative Shrink-Fill 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 `StripNestEngine`'s single-strip approach with iterative shrink-fill — every multi-quantity drawing gets shrink-fitted into its tightest sub-region using dual-direction selection, with leftovers packed at the end.
**Architecture:** New `IterativeShrinkFiller` static class composes existing `RemnantFiller` + `ShrinkFiller` with a dual-direction wrapper closure. `StripNestEngine.Nest` becomes a thin orchestrator calling the new filler then packing leftovers. No changes to `NestEngineBase`, `DefaultNestEngine`, or UI.
**Tech Stack:** .NET 8, xUnit, OpenNest.Engine
**Spec:** `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
---
### File Structure
| File | Responsibility |
|------|---------------|
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | **New.** Static class + result type. Wraps a raw fill function with dual-direction `ShrinkFiller.Shrink`, passes the wrapper to `RemnantFiller.FillItems`. Returns placed parts + leftover items. |
| `OpenNest.Engine/StripNestEngine.cs` | **Modify.** Rewrite `Nest` to separate items, call `IterativeShrinkFiller.Fill`, pack leftovers. Delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, `ShrinkFill`. |
| `OpenNest.Engine/StripNestResult.cs` | **Delete.** No longer needed. |
| `OpenNest.Engine/StripDirection.cs` | **Delete.** No longer needed. |
| `OpenNest.Tests/IterativeShrinkFillerTests.cs` | **New.** Unit tests for the new filler. |
| `OpenNest.Tests/EngineRefactorSmokeTests.cs` | **Verify.** Existing `StripEngine_Nest_ProducesResults` must still pass. |
---
### Task 1: IterativeShrinkFiller — empty/null input
**Files:**
- Create: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
- Create: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
- [ ] **Step 1: Write failing tests for empty/null input**
```csharp
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class IterativeShrinkFillerTests
{
[Fact]
public void Fill_NullItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
[Fact]
public void Fill_EmptyItems_ReturnsEmpty()
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
Assert.Empty(result.Parts);
Assert.Empty(result.Leftovers);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: Build error — `IterativeShrinkFiller` does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Create `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`:
```csharp
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Fill
{
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; } = new();
public List<NestItem> Leftovers { get; set; } = new();
}
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// TODO: dual-direction shrink logic
return new IterativeShrinkResult();
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: 2 tests pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Tests/IterativeShrinkFillerTests.cs OpenNest.Engine/Fill/IterativeShrinkFiller.cs
git commit -m "feat(engine): add IterativeShrinkFiller skeleton with empty/null tests"
```
---
### Task 2: IterativeShrinkFiller — dual-direction shrink core logic
**Files:**
- Modify: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
**Context:** The core algorithm wraps the caller's `fillFunc` in a closure that calls `ShrinkFiller.Shrink` in both axis directions and picks the better `FillScore`, then passes this wrapper to `RemnantFiller.FillItems`.
- [ ] **Step 1: Write failing test — single item gets shrink-filled**
Add to `IterativeShrinkFillerTests.cs`:
```csharp
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 Fill_SingleItem_PlacesParts()
{
var drawing = MakeRectDrawing(20, 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 result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place parts");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests.Fill_SingleItem_PlacesParts"`
Expected: FAIL — returns 0 parts (skeleton returns empty).
- [ ] **Step 3: Implement dual-direction shrink logic**
Replace the TODO in `IterativeShrinkFiller.Fill`:
```csharp
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token = default)
{
if (items == null || items.Count == 0)
return new IterativeShrinkResult();
// RemnantFiller.FillItems skips items with Quantity <= 0 (its localQty
// check treats them as "done"). Convert unlimited items to an estimated
// max capacity so they are actually processed.
var workItems = new List<NestItem>(items.Count);
var unlimitedDrawings = new HashSet<string>();
foreach (var item in items)
{
if (item.Quantity <= 0)
{
var bbox = item.Drawing.Program.BoundingBox();
var estimatedMax = bbox.Area() > 0
? (int)(workArea.Area() / bbox.Area()) * 2
: 1000;
unlimitedDrawings.Add(item.Drawing.Name);
workItems.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = System.Math.Max(1, estimatedMax),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
else
{
workItems.Add(item);
}
}
var filler = new RemnantFiller(workArea, spacing);
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token);
var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token);
var heightScore = FillScore.Compute(heightResult.Parts, box);
var widthScore = FillScore.Compute(widthResult.Parts, box);
return widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
};
var placed = filler.FillItems(workItems, shrinkWrapper, token);
// Build leftovers: compare placed count to original quantities.
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
var leftovers = new List<NestItem>();
foreach (var item in items)
{
var placedCount = placed.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
if (item.Quantity <= 0)
continue; // unlimited items are always "satisfied" — no leftover
var remaining = item.Quantity - placedCount;
if (remaining > 0)
{
leftovers.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = remaining,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd
});
}
}
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
}
```
**Key points:**
- `RemnantFiller.FillItems` skips items with `Quantity <= 0` (its `localQty` check treats them as done). To work around this without modifying `RemnantFiller`, unlimited items are converted to an estimated max capacity (`workArea / bboxArea * 2`) before being passed in.
- `RemnantFiller.FillItems` does NOT mutate `NestItem.Quantity` — it tracks quantities internally via `localQty` dictionary (verified in `RemnantFillerTests2.FillItems_DoesNotMutateItemQuantities`).
- The leftover calculation iterates the *original* items list (not `workItems`), so unlimited items are correctly skipped.
- `FillScore` comparison: `widthScore > heightScore` uses the operator overload which is lexicographic (count first, then density).
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/Fill/IterativeShrinkFiller.cs OpenNest.Tests/IterativeShrinkFillerTests.cs
git commit -m "feat(engine): implement dual-direction shrink logic in IterativeShrinkFiller"
```
---
### Task 3: IterativeShrinkFiller — multiple items and leftovers
**Files:**
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
- [ ] **Step 1: Write failing tests for multi-item and leftover scenarios**
Add to `IterativeShrinkFillerTests.cs`:
```csharp
[Fact]
public void Fill_MultipleItems_PlacesFromBoth()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), 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 result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
Assert.True(largeCount > 0, "Should place large parts");
Assert.True(smallCount > 0, "Should place small parts in remaining space");
}
[Fact]
public void Fill_UnfilledQuantity_ReturnsLeftovers()
{
// Huge quantity that can't all fit on a small plate
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
};
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 = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Should place some parts");
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
}
[Fact]
public void Fill_UnlimitedQuantity_PlacesParts()
{
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
};
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 = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
}
[Fact]
public void Fill_RespectsCancellation()
{
var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
var items = new List<NestItem>
{
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
Assert.NotNull(result);
}
```
- [ ] **Step 2: Run tests**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
Expected: All 7 tests pass (these test existing behavior, no new code needed — they verify the implementation from Task 2 handles these cases).
If any fail, fix the implementation in `IterativeShrinkFiller.Fill` and re-run.
- [ ] **Step 3: Commit**
```bash
git add OpenNest.Tests/IterativeShrinkFillerTests.cs
git commit -m "test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller"
```
---
### Task 4: Rewrite StripNestEngine.Nest
**Files:**
- Modify: `OpenNest.Engine/StripNestEngine.cs`
**Context:** Replace the current `Nest` implementation that does single-strip + remnant fill with the new iterative approach. Keep `Fill`, `Fill(groupParts)`, and `PackArea` overrides unchanged — they still delegate to `DefaultNestEngine`.
- [ ] **Step 1: Run existing smoke test to establish baseline**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
Expected: PASS (current implementation works).
- [ ] **Step 2: Rewrite StripNestEngine.Nest**
Replace the `Nest` override and delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, and `ShrinkFill` methods. The full file should become:
```csharp
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest
{
public class StripNestEngine : NestEngineBase
{
public StripNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Strip";
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
/// <summary>
/// Group-parts fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(groupParts, workArea, progress, token);
}
/// <summary>
/// Pack delegates to DefaultNestEngine.
/// </summary>
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.PackArea(box, items, progress, token);
}
/// <summary>
/// Multi-drawing iterative shrink-fill strategy.
/// Each multi-quantity drawing gets shrink-filled into the tightest
/// sub-region using dual-direction selection. Singles and leftovers
/// are packed at the end.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
// Separate multi-quantity from singles.
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
var allParts = new List<Part>();
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token);
allParts.AddRange(shrinkResult.Parts);
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
}
// Phase 2: Pack singles + leftovers into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && !token.IsCancellationRequested)
{
// Reconstruct remaining area from placed parts.
var packArea = workArea;
if (allParts.Count > 0)
{
var obstacles = allParts
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
.ToList();
var finder = new RemnantFinder(workArea, obstacles);
var remnants = finder.FindRemnants();
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
}
if (packArea.Width > 0 && packArea.Length > 0)
{
var packParts = PackArea(packArea, packItems, progress, token);
allParts.AddRange(packParts);
}
}
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return allParts;
}
}
}
```
- [ ] **Step 3: Run smoke test**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
Expected: PASS.
- [ ] **Step 4: Run all engine tests to check for regressions**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EngineRefactorSmokeTests|FullyQualifiedName~IterativeShrinkFillerTests|FullyQualifiedName~ShrinkFillerTests|FullyQualifiedName~RemnantFillerTests"`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/StripNestEngine.cs
git commit -m "feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill"
```
---
### Task 5: Delete obsolete files
**Files:**
- Delete: `OpenNest.Engine/StripNestResult.cs`
- Delete: `OpenNest.Engine/StripDirection.cs`
- [ ] **Step 1: Verify no remaining references**
Run: `grep -r "StripNestResult\|StripDirection" --include="*.cs" . | grep -v "\.md"`
Expected: No matches (all references were in the old `StripNestEngine.TryOrientation` which was deleted in Task 4).
- [ ] **Step 2: Delete the files**
```bash
rm OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
```
- [ ] **Step 3: Build to verify no breakage**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds with no errors.
- [ ] **Step 4: Run full test suite**
Run: `dotnet test OpenNest.Tests`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add -u OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
git commit -m "refactor(engine): delete obsolete StripNestResult and StripDirection"
```
---
### Task 6: Update spec and docs
**Files:**
- Modify: `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
- [ ] **Step 1: Update spec with "rotating calipers already included" note**
The spec was already updated during planning. Verify it reflects the final state — no `AngleCandidateBuilder` or `NestItem.CaliperAngle` changes listed.
- [ ] **Step 2: Commit if any changes**
```bash
git add docs/
git commit -m "docs: finalize iterative shrink-fill spec"
```

View File

@@ -4,8 +4,6 @@
`StripNestEngine` currently picks a single "strip" drawing (the highest-area item), shrink-fills it into the tightest sub-region, then fills remnants with remaining drawings. This wastes potential density — every drawing benefits from shrink-filling into its tightest sub-region, not just the first one.
Additionally, `AngleCandidateBuilder` does not include the rotating calipers minimum bounding rectangle angle, despite it being the mathematically optimal tight-fit rotation for rectangular work areas.
## Design
### 1. IterativeShrinkFiller
@@ -52,42 +50,18 @@ public static class IterativeShrinkFiller
The class composes `RemnantFiller` and `ShrinkFiller` — it does not duplicate their logic.
### 2. Rotating Calipers Angle in AngleCandidateBuilder
### 2. Revised StripNestEngine.Nest
Add the rotating calipers minimum bounding rectangle angle to the base angles in `AngleCandidateBuilder.Build`.
**Current:** `baseAngles = [bestRotation, bestRotation + 90°]`
**Proposed:** `baseAngles = [bestRotation, bestRotation + 90°, caliperAngle, caliperAngle + 90°]` (deduplicated)
The caliper angle is pre-computed and cached on `NestItem.CaliperAngle` to avoid recomputing the pipeline (`Program.ToGeometry()``ShapeProfile``ToPolygonWithTolerance``RotatingCalipers.MinimumBoundingRectangle`) on every fill call.
This feeds into every downstream path (pruned known-good list, sweep, ML prediction) since they all start from `baseAngles`.
### 3. CaliperAngle on NestItem
Add a `double? CaliperAngle` property (radians) to `NestItem`. Pre-computed by the caller before passing items to the engine. When null, `AngleCandidateBuilder` skips the caliper angles (backward compatible).
Computation pipeline:
```csharp
var geometry = item.Drawing.Program.ToGeometry();
var shapeProfile = new ShapeProfile(geometry);
var polygon = shapeProfile.Perimeter.ToPolygonWithTolerance(0.001, circumscribe: true);
var result = RotatingCalipers.MinimumBoundingRectangle(polygon);
item.CaliperAngle = result.Angle;
```
### 4. Revised StripNestEngine.Nest
**Note:** The rotating calipers angle is already included via `RotationAnalysis.FindBestRotation`, which calls `RotatingCalipers.MinimumBoundingRectangle` and feeds the result as `bestRotation` into `AngleCandidateBuilder.Build`. No changes needed to the angle pipeline.
The `Nest` override becomes a thin orchestrator:
1. Separate items into multi-quantity (qty != 1) and singles (qty == 1).
2. Pre-compute and cache `CaliperAngle` on each item's `NestItem`.
3. Sort multi-quantity items by `Priority` ascending, then `Drawing.Area` descending.
4. Call `IterativeShrinkFiller.Fill` with the sorted multi-quantity items.
5. Collect leftovers: unfilled multi-quantity remainders + all singles.
6. If leftovers exist and free space remains, run `PackArea` into the remaining area.
7. Deduct placed quantities from the original items. Return all parts.
2. Sort multi-quantity items by `Priority` ascending, then `Drawing.Area` descending.
3. Call `IterativeShrinkFiller.Fill` with the sorted multi-quantity items.
4. Collect leftovers: unfilled multi-quantity remainders + all singles.
5. If leftovers exist and free space remains, run `PackArea` into the remaining area.
6. Deduct placed quantities from the original items. Return all parts.
**Deleted code:**
- `SelectStripItemIndex` method
@@ -104,8 +78,6 @@ The `Nest` override becomes a thin orchestrator:
| File | Change |
|------|--------|
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | New — orchestrates RemnantFiller + ShrinkFiller with dual-direction selection |
| `OpenNest.Engine/Fill/AngleCandidateBuilder.cs` | Add caliper angle + 90° to base angles from NestItem.CaliperAngle |
| `OpenNest.Engine/NestItem.cs` | Add `double? CaliperAngle` property |
| `OpenNest.Engine/StripNestEngine.cs` | Rewrite Nest to use IterativeShrinkFiller + pack leftovers |
| `OpenNest.Engine/StripNestResult.cs` | Delete |
| `OpenNest.Engine/StripDirection.cs` | Delete |
@@ -113,4 +85,4 @@ The `Nest` override becomes a thin orchestrator:
## Not In Scope
- Trying multiple item orderings and picking the best overall `FillScore` — future follow-up once we confirm the iterative approach is fast enough.
- Changes to `NestEngineBase`, `DefaultNestEngine`, `RemnantFiller`, `ShrinkFiller`, `RemnantFinder`, or UI code.
- Changes to `NestEngineBase`, `DefaultNestEngine`, `RemnantFiller`, `ShrinkFiller`, `RemnantFinder`, `AngleCandidateBuilder`, `NestItem`, or UI code.