From 1bc635acded4df7086c5c6a7fe8e0acb1a7bcfbc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Mar 2026 10:26:08 -0400 Subject: [PATCH] docs: add iterative shrink-fill implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-03-19-iterative-shrink-fill.md | 620 ++++++++++++++++++ ...2026-03-19-iterative-shrink-fill-design.md | 44 +- 2 files changed, 628 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md diff --git a/docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md b/docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md new file mode 100644 index 0000000..0241950 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-iterative-shrink-fill.md @@ -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> fillFunc = (ni, b) => new List(); + 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> fillFunc = (ni, b) => new List(); + var result = IterativeShrinkFiller.Fill(new List(), 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 Parts { get; set; } = new(); + public List Leftovers { get; set; } = new(); + } + + public static class IterativeShrinkFiller + { + public static IterativeShrinkResult Fill( + List items, + Box workArea, + Func> 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 + { + 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 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 items, + Box workArea, + Func> 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(items.Count); + var unlimitedDrawings = new HashSet(); + + 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> 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(); + 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 + { + new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 }, + new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), 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 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 + { + new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 }, + }; + + 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 = 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 + { + new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 } + }; + + 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 = 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 + { + new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 } + }; + + Func> fillFunc = (ni, b) => + new List { 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"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + /// + /// Group-parts fill delegates to DefaultNestEngine. + /// + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(groupParts, workArea, progress, token); + } + + /// + /// Pack delegates to DefaultNestEngine. + /// + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.PackArea(box, items, progress, token); + } + + /// + /// 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. + /// + public override List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + 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(); + + // Phase 1: Iterative shrink-fill for multi-quantity items. + if (fillItems.Count > 0) + { + Func> 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" +``` diff --git a/docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md b/docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md index a100277..5fb6dc7 100644 --- a/docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md +++ b/docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md @@ -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.