From 3705d505460087ae0e0791d0f523350b1a84c12b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 9 Mar 2026 19:15:42 -0400 Subject: [PATCH] feat: add remainder strip re-fill to improve pattern fill density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the main fill, detect if the last column/row is an "oddball" with fewer parts than the main grid. If so, remove those parts and re-fill the remainder strip independently using all strategies (linear, rect best-fit, pairs). Improves 30→32 parts on the test case (96x48 plate with 30x7.5 interlocking parts). Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/NestEngine.cs | 180 ++++++++- .../2026-03-09-remainder-strip-refill.md | 382 ++++++++++++++++++ 2 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-03-09-remainder-strip-refill.md diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 0aba633..454dbe4 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -30,6 +30,29 @@ namespace OpenNest } public bool Fill(NestItem item, Box workArea) + { + var best = FindBestFill(item, workArea); + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(item, workArea, best); + + if (IsBetterFill(improved, best)) + { + Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + } + + if (best == null || best.Count == 0) + return false; + + if (item.Quantity > 0 && best.Count > item.Quantity) + best = best.Take(item.Quantity).ToList(); + + Plate.Parts.AddRange(best); + return true; + } + + private List FindBestFill(NestItem item, Box workArea) { var bestRotation = RotationAnalysis.FindBestRotation(item); @@ -76,12 +99,12 @@ namespace OpenNest best = v; } - Debug.WriteLine($"[Fill(NestItem,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}"); + Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}"); // Try rectangle best-fit (mixes orientations to fill remnant strips). var rectResult = FillRectangleBestFit(item, workArea); - Debug.WriteLine($"[Fill(NestItem,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); if (IsBetterFill(rectResult, best)) best = rectResult; @@ -89,19 +112,12 @@ namespace OpenNest // Try pair-based approach. var pairResult = FillWithPairs(item, workArea); - Debug.WriteLine($"[Fill(NestItem,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best) ? "Pair" : "Linear")}"); + Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best) ? "Pair" : "Linear")}"); if (IsBetterFill(pairResult, best)) best = pairResult; - if (best == null || best.Count == 0) - return false; - - if (item.Quantity > 0 && best.Count > item.Quantity) - best = best.Take(item.Quantity).ToList(); - - Plate.Parts.AddRange(best); - return true; + return best; } public bool Fill(List groupParts, Box workArea) @@ -131,6 +147,15 @@ namespace OpenNest if (IsBetterFill(pairResult, best)) best = pairResult; + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(nestItem, workArea, best); + + if (IsBetterFill(improved, best)) + { + Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + } } if (best == null || best.Count == 0) @@ -286,6 +311,139 @@ namespace OpenNest return IsBetterFill(candidate, current); } + /// + /// Groups parts into positional clusters along the given axis. + /// Parts whose center positions are separated by more than half + /// the part dimension start a new cluster. + /// + private static List> ClusterParts(List parts, bool horizontal) + { + var sorted = horizontal + ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + + var refDim = horizontal + ? sorted.Max(p => p.BoundingBox.Width) + : sorted.Max(p => p.BoundingBox.Height); + var gapThreshold = refDim * 0.5; + + var clusters = new List>(); + var current = new List { sorted[0] }; + + for (var i = 1; i < sorted.Count; i++) + { + var prevCenter = horizontal + ? sorted[i - 1].BoundingBox.Center.X + : sorted[i - 1].BoundingBox.Center.Y; + var currCenter = horizontal + ? sorted[i].BoundingBox.Center.X + : sorted[i].BoundingBox.Center.Y; + + if (currCenter - prevCenter > gapThreshold) + { + clusters.Add(current); + current = new List(); + } + + current.Add(sorted[i]); + } + + clusters.Add(current); + return clusters; + } + + private List TryStripRefill(NestItem item, Box workArea, List parts, bool horizontal) + { + if (parts == null || parts.Count < 3) + return null; + + var clusters = ClusterParts(parts, horizontal); + + if (clusters.Count < 2) + return null; + + // Determine the mode (most common) cluster count, excluding the last cluster. + var mainClusters = clusters.Take(clusters.Count - 1).ToList(); + var modeCount = mainClusters + .GroupBy(c => c.Count) + .OrderByDescending(g => g.Count()) + .First() + .Key; + + var lastCluster = clusters[clusters.Count - 1]; + + // Only attempt refill if the last cluster is smaller than the mode. + if (lastCluster.Count >= modeCount) + return null; + + Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} clusters: {clusters.Count}, mode: {modeCount}, last: {lastCluster.Count}"); + + // Build the main parts list (everything except the last cluster). + var mainParts = clusters.Take(clusters.Count - 1).SelectMany(c => c).ToList(); + var mainBox = ((IEnumerable)mainParts).GetBoundingBox(); + + // Compute the strip box from the main grid edge to the work area edge. + Box stripBox; + + if (horizontal) + { + var stripLeft = mainBox.Right + Plate.PartSpacing; + var stripWidth = workArea.Right - stripLeft; + + if (stripWidth <= 0) + return null; + + stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height); + } + else + { + var stripBottom = mainBox.Top + Plate.PartSpacing; + var stripHeight = workArea.Top - stripBottom; + + if (stripHeight <= 0) + return null; + + stripBox = new Box(workArea.X, stripBottom, workArea.Width, stripHeight); + } + + Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Height:F1} at ({stripBox.X:F1},{stripBox.Y:F1})"); + + var stripParts = FindBestFill(item, stripBox); + + if (stripParts == null || stripParts.Count <= lastCluster.Count) + { + Debug.WriteLine($"[TryStripRefill] No improvement: strip={stripParts?.Count ?? 0} vs oddball={lastCluster.Count}"); + return null; + } + + Debug.WriteLine($"[TryStripRefill] Improvement: strip={stripParts.Count} vs oddball={lastCluster.Count}"); + + var combined = new List(mainParts.Count + stripParts.Count); + combined.AddRange(mainParts); + combined.AddRange(stripParts); + return combined; + } + + private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) + { + if (currentBest == null || currentBest.Count < 3) + return null; + + List best = null; + + var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true); + + if (IsBetterFill(hResult, best)) + best = hResult; + + var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false); + + if (IsBetterFill(vResult, best)) + best = vResult; + + return best; + } + private Pattern BuildRotatedPattern(List groupParts, double angle) { var pattern = new Pattern(); diff --git a/docs/plans/2026-03-09-remainder-strip-refill.md b/docs/plans/2026-03-09-remainder-strip-refill.md new file mode 100644 index 0000000..5fa1f5b --- /dev/null +++ b/docs/plans/2026-03-09-remainder-strip-refill.md @@ -0,0 +1,382 @@ +# Remainder Strip Re-Fill Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** After the main fill, detect oddball last column/row, remove it, and re-fill the remainder strip independently to maximize part count (30 -> 32 for the test case). + +**Architecture:** Extract the strategy selection logic from `Fill(NestItem, Box)` into a reusable `FindBestFill` method. Add `TryRemainderImprovement` that clusters placed parts, detects oddball last cluster, computes the remainder strip box, and calls `FindBestFill` on it. Only used when it improves the count. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine + +--- + +### Task 1: Extract FindBestFill from Fill(NestItem, Box) + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs:32-105` + +**Step 1: Create `FindBestFill` by extracting the strategy logic** + +Move lines 34-95 (everything except the quantity check and `Plate.Parts.AddRange`) into a new private method. `Fill` delegates to it. + +```csharp +private List FindBestFill(NestItem item, Box workArea) +{ + var bestRotation = RotationAnalysis.FindBestRotation(item); + + var engine = new FillLinear(workArea, Plate.PartSpacing); + + // Build candidate rotation angles — always try the best rotation and +90°. + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + + // When the work area is narrow relative to the part, sweep rotation + // angles so we can find one that fits the part into the tight strip. + 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.Height); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height); + + if (workAreaShortSide < partLongestSide) + { + // Try every 5° from 0 to 175° to find rotations that fit. + 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); + } + } + + List best = null; + + foreach (var angle in angles) + { + var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal); + var v = engine.Fill(item.Drawing, angle, NestDirection.Vertical); + + if (IsBetterFill(h, best)) + best = h; + + if (IsBetterFill(v, best)) + best = v; + } + + Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}"); + + // Try rectangle best-fit (mixes orientations to fill remnant strips). + var rectResult = FillRectangleBestFit(item, workArea); + + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best)) + best = rectResult; + + // Try pair-based approach. + var pairResult = FillWithPairs(item, workArea); + + Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts"); + + if (IsBetterFill(pairResult, best)) + best = pairResult; + + return best; +} +``` + +**Step 2: Simplify `Fill(NestItem, Box)` to delegate** + +```csharp +public bool Fill(NestItem item, Box workArea) +{ + var best = FindBestFill(item, workArea); + + if (best == null || best.Count == 0) + return false; + + if (item.Quantity > 0 && best.Count > item.Quantity) + best = best.Take(item.Quantity).ToList(); + + Plate.Parts.AddRange(best); + return true; +} +``` + +**Step 3: Build and verify no regressions** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds, no errors. + +**Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "refactor: extract FindBestFill from Fill(NestItem, Box)" +``` + +--- + +### Task 2: Add ClusterParts helper + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Step 1: Add the `ClusterParts` method** + +Place after `IsBetterValidFill` (around line 287). Groups parts into positional clusters (columns or rows) based on center position gaps. + +```csharp +/// +/// Groups parts into positional clusters along the given axis. +/// Parts whose center positions are separated by more than half +/// the part dimension start a new cluster. +/// +private static List> ClusterParts(List parts, bool horizontal) +{ + var sorted = horizontal + ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + + var refDim = horizontal + ? sorted.Max(p => p.BoundingBox.Width) + : sorted.Max(p => p.BoundingBox.Height); + var gapThreshold = refDim * 0.5; + + var clusters = new List>(); + var current = new List { sorted[0] }; + + for (var i = 1; i < sorted.Count; i++) + { + var prevCenter = horizontal + ? sorted[i - 1].BoundingBox.Center.X + : sorted[i - 1].BoundingBox.Center.Y; + var currCenter = horizontal + ? sorted[i].BoundingBox.Center.X + : sorted[i].BoundingBox.Center.Y; + + if (currCenter - prevCenter > gapThreshold) + { + clusters.Add(current); + current = new List(); + } + + current.Add(sorted[i]); + } + + clusters.Add(current); + return clusters; +} +``` + +**Step 2: Build** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat: add ClusterParts helper for positional grouping" +``` + +--- + +### Task 3: Add TryStripRefill and TryRemainderImprovement + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Step 1: Add `TryStripRefill`** + +This method analyzes one axis: clusters parts, checks if last cluster is an oddball, computes the strip, and fills it. + +```csharp +/// +/// Checks whether the last column (horizontal) or row (vertical) is an +/// oddball with fewer parts than the main grid. If so, removes those parts, +/// computes the remainder strip, and fills it independently. +/// Returns null if no improvement is possible. +/// +private List TryStripRefill(NestItem item, Box workArea, List parts, bool horizontal) +{ + var clusters = ClusterParts(parts, horizontal); + + if (clusters.Count < 2) + return null; + + var lastCluster = clusters[clusters.Count - 1]; + var otherClusters = clusters.Take(clusters.Count - 1).ToList(); + + // Find the most common cluster size (mode). + var modeCount = otherClusters + .Select(c => c.Count) + .GroupBy(x => x) + .OrderByDescending(g => g.Count()) + .First().Key; + + // Only proceed if last cluster is smaller (it's the oddball). + if (lastCluster.Count >= modeCount) + return null; + + var mainParts = otherClusters.SelectMany(c => c).ToList(); + var mainBbox = ((IEnumerable)mainParts).GetBoundingBox(); + + Box strip; + + if (horizontal) + { + var stripLeft = mainBbox.Right + Plate.PartSpacing; + var stripWidth = workArea.Right - stripLeft; + + if (stripWidth < 1) + return null; + + strip = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height); + } + else + { + var stripBottom = mainBbox.Top + Plate.PartSpacing; + var stripHeight = workArea.Top - stripBottom; + + if (stripHeight < 1) + return null; + + strip = new Box(workArea.X, stripBottom, workArea.Width, stripHeight); + } + + Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} strip: {strip.Width:F1}x{strip.Height:F1} | Main: {mainParts.Count} | Oddball: {lastCluster.Count}"); + + var stripParts = FindBestFill(item, strip); + + if (stripParts == null || stripParts.Count <= lastCluster.Count) + return null; + + Debug.WriteLine($"[TryStripRefill] Strip fill: {stripParts.Count} parts (was {lastCluster.Count} oddball)"); + + var combined = new List(mainParts); + combined.AddRange(stripParts); + return combined; +} +``` + +**Step 2: Add `TryRemainderImprovement`** + +Tries both horizontal and vertical strip analysis. + +```csharp +/// +/// Attempts to improve a fill result by detecting an oddball last +/// column or row and re-filling the remainder strip independently. +/// Returns null if no improvement is found. +/// +private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) +{ + if (currentBest == null || currentBest.Count < 3) + return null; + + List bestImproved = null; + + var hImproved = TryStripRefill(item, workArea, currentBest, horizontal: true); + + if (IsBetterFill(hImproved, bestImproved)) + bestImproved = hImproved; + + var vImproved = TryStripRefill(item, workArea, currentBest, horizontal: false); + + if (IsBetterFill(vImproved, bestImproved)) + bestImproved = vImproved; + + return bestImproved; +} +``` + +**Step 3: Build** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +**Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat: add TryStripRefill and TryRemainderImprovement" +``` + +--- + +### Task 4: Wire remainder improvement into Fill + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` — the `Fill(NestItem, Box)` method + +**Step 1: Add remainder improvement call** + +Update `Fill(NestItem, Box)` to try improving the result after the initial fill: + +```csharp +public bool Fill(NestItem item, Box workArea) +{ + var best = FindBestFill(item, workArea); + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(item, workArea, best); + + if (IsBetterFill(improved, best)) + { + Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + } + + if (best == null || best.Count == 0) + return false; + + if (item.Quantity > 0 && best.Count > item.Quantity) + best = best.Take(item.Quantity).ToList(); + + Plate.Parts.AddRange(best); + return true; +} +``` + +**Step 2: Build** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat: wire remainder strip re-fill into Fill(NestItem, Box)" +``` + +--- + +### Task 5: Verify with MCP tools + +**Step 1: Publish MCP server** + +```bash +dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp" +``` + +**Step 2: Test fill** + +Use MCP tools to: +1. Import the DXF drawing from `30pcs Fill.zip` (or create equivalent plate + drawing) +2. Create a 96x48 plate with the same spacing (part=0.25, edges L=0.25 B=0.75 R=0.25 T=0.25) +3. Fill the plate +4. Verify part count is 32 (up from 30) +5. Check for overlaps + +**Step 3: Compare against 32pcs reference** + +Verify the layout matches the 32pcs.zip reference — 24 parts in the main grid + 8 in the remainder strip. + +**Step 4: Final commit if any fixups needed**