diff --git a/docs/superpowers/plans/2026-03-16-remnant-finder.md b/docs/superpowers/plans/2026-03-16-remnant-finder.md new file mode 100644 index 0000000..7dc76cc --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-remnant-finder.md @@ -0,0 +1,998 @@ +# Remnant Finder Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract remnant detection from the nesting engine into a standalone `RemnantFinder` class that finds all rectangular empty regions via edge projection, and visualize the active work area on the plate view. + +**Architecture:** `RemnantFinder` is a mutable class in `OpenNest.Engine` that takes a work area + obstacle boxes and uses edge projection to find empty rectangles. The remainder phase is removed from `DefaultNestEngine`, making `Fill()` single-pass. `FillScore` drops remnant tracking. `PlateView` gains a dashed orange rectangle overlay for the active work area. `NestProgress` carries `ActiveWorkArea` so callers can show which region is currently being filled. + +**Tech Stack:** .NET 8, C#, xUnit, WinForms (GDI+) + +**Spec:** `docs/superpowers/specs/2026-03-16-remnant-finder-design.md` + +--- + +## Chunk 1: RemnantFinder Core + +### Task 1: RemnantFinder — failing tests + +**Files:** +- Create: `OpenNest.Tests/RemnantFinderTests.cs` + +- [ ] **Step 1: Write failing tests for RemnantFinder** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class RemnantFinderTests +{ + [Fact] + public void EmptyPlate_ReturnsWholeWorkArea() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + var remnants = finder.FindRemnants(); + + Assert.Single(remnants); + Assert.Equal(100 * 100, remnants[0].Area(), 0.1); + } + + [Fact] + public void SingleObstacle_InCorner_FindsLShapedRemnants() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + finder.AddObstacle(new Box(0, 0, 40, 40)); + var remnants = finder.FindRemnants(); + + // Should find at least the right strip (60x100) and top strip (40x60) + Assert.True(remnants.Count >= 2); + + // Largest remnant should be the right strip + var largest = remnants[0]; + Assert.Equal(60 * 100, largest.Area(), 0.1); + } + + [Fact] + public void SingleObstacle_InCenter_FindsFourRemnants() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + finder.AddObstacle(new Box(30, 30, 40, 40)); + var remnants = finder.FindRemnants(); + + // Should find remnants on all four sides + Assert.True(remnants.Count >= 4); + } + + [Fact] + public void MinDimension_FiltersSmallRemnants() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + // Obstacle leaves a 5-wide strip on the right + finder.AddObstacle(new Box(0, 0, 95, 100)); + var all = finder.FindRemnants(0); + var filtered = finder.FindRemnants(10); + + Assert.True(all.Count > filtered.Count); + foreach (var r in filtered) + { + Assert.True(r.Width >= 10); + Assert.True(r.Length >= 10); + } + } + + [Fact] + public void ResultsSortedByAreaDescending() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + finder.AddObstacle(new Box(0, 0, 50, 50)); + var remnants = finder.FindRemnants(); + + for (var i = 1; i < remnants.Count; i++) + Assert.True(remnants[i - 1].Area() >= remnants[i].Area()); + } + + [Fact] + public void AddObstacle_UpdatesResults() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + var before = finder.FindRemnants(); + Assert.Single(before); + + finder.AddObstacle(new Box(0, 0, 50, 50)); + var after = finder.FindRemnants(); + Assert.True(after.Count > 1); + } + + [Fact] + public void ClearObstacles_ResetsToFullWorkArea() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + finder.AddObstacle(new Box(0, 0, 50, 50)); + finder.ClearObstacles(); + var remnants = finder.FindRemnants(); + + Assert.Single(remnants); + Assert.Equal(100 * 100, remnants[0].Area(), 0.1); + } + + [Fact] + public void FullyCovered_ReturnsEmpty() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + finder.AddObstacle(new Box(0, 0, 100, 100)); + var remnants = finder.FindRemnants(); + + Assert.Empty(remnants); + } + + [Fact] + public void MultipleObstacles_FindsGapBetween() + { + var finder = new RemnantFinder(new Box(0, 0, 100, 100)); + // Two obstacles with a 20-wide gap in the middle + finder.AddObstacle(new Box(0, 0, 40, 100)); + finder.AddObstacle(new Box(60, 0, 40, 100)); + var remnants = finder.FindRemnants(); + + // Should find the 20x100 gap between the two obstacles + var gap = remnants.FirstOrDefault(r => + r.Width >= 19.9 && r.Width <= 20.1 && + r.Length >= 99.9); + Assert.NotNull(gap); + } + + [Fact] + public void FromPlate_CreatesFinderWithPartsAsObstacles() + { + var plate = TestHelpers.MakePlate(60, 120, + TestHelpers.MakePartAt(0, 0, 20)); + var finder = RemnantFinder.FromPlate(plate); + var remnants = finder.FindRemnants(); + + // Should have remnants around the 20x20 part + Assert.True(remnants.Count >= 1); + // Largest remnant area should be less than full plate work area + Assert.True(remnants[0].Area() < plate.WorkArea().Area()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal` +Expected: FAIL — `RemnantFinder` class does not exist + +- [ ] **Step 3: Commit failing tests** + +```bash +git add OpenNest.Tests/RemnantFinderTests.cs +git commit -m "test: add RemnantFinder tests (red)" +``` + +### Task 2: RemnantFinder — implementation + +**Files:** +- Create: `OpenNest.Engine/RemnantFinder.cs` + +- [ ] **Step 1: Implement RemnantFinder** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + public class RemnantFinder + { + private readonly Box workArea; + + public List Obstacles { get; } = new(); + + public RemnantFinder(Box workArea, List obstacles = null) + { + this.workArea = workArea; + + if (obstacles != null) + Obstacles.AddRange(obstacles); + } + + public void AddObstacle(Box obstacle) => Obstacles.Add(obstacle); + + public void AddObstacles(IEnumerable obstacles) => Obstacles.AddRange(obstacles); + + public void ClearObstacles() => Obstacles.Clear(); + + public List FindRemnants(double minDimension = 0) + { + // Step 1-2: Collect unique X and Y coordinates + var xs = new SortedSet { workArea.Left, workArea.Right }; + var ys = new SortedSet { workArea.Bottom, workArea.Top }; + + foreach (var obs in Obstacles) + { + var clipped = ClipToWorkArea(obs); + if (clipped.Width <= 0 || clipped.Length <= 0) + continue; + + xs.Add(clipped.Left); + xs.Add(clipped.Right); + ys.Add(clipped.Bottom); + ys.Add(clipped.Top); + } + + var xList = xs.ToList(); + var yList = ys.ToList(); + + // Step 3-4: Build grid cells and mark empty ones + var cols = xList.Count - 1; + var rows = yList.Count - 1; + + if (cols <= 0 || rows <= 0) + return new List(); + + var empty = new bool[rows, cols]; + + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + var cell = new Box(xList[c], yList[r], + xList[c + 1] - xList[c], yList[r + 1] - yList[r]); + + empty[r, c] = !OverlapsAnyObstacle(cell); + } + } + + // Step 5: Merge adjacent empty cells into larger rectangles + var merged = MergeCells(empty, xList, yList, rows, cols); + + // Step 6: Filter by minDimension + var results = new List(); + + foreach (var box in merged) + { + if (box.Width >= minDimension && box.Length >= minDimension) + results.Add(box); + } + + // Step 7: Sort by area descending + results.Sort((a, b) => b.Area().CompareTo(a.Area())); + return results; + } + + public static RemnantFinder FromPlate(Plate plate) + { + var obstacles = new List(plate.Parts.Count); + + foreach (var part in plate.Parts) + obstacles.Add(part.BoundingBox.Offset(plate.PartSpacing)); + + return new RemnantFinder(plate.WorkArea(), obstacles); + } + + private Box ClipToWorkArea(Box obs) + { + var left = System.Math.Max(obs.Left, workArea.Left); + var bottom = System.Math.Max(obs.Bottom, workArea.Bottom); + var right = System.Math.Min(obs.Right, workArea.Right); + var top = System.Math.Min(obs.Top, workArea.Top); + + if (right <= left || top <= bottom) + return Box.Empty; + + return new Box(left, bottom, right - left, top - bottom); + } + + private bool OverlapsAnyObstacle(Box cell) + { + foreach (var obs in Obstacles) + { + var clipped = ClipToWorkArea(obs); + + if (clipped.Width <= 0 || clipped.Length <= 0) + continue; + + if (cell.Left < clipped.Right && + cell.Right > clipped.Left && + cell.Bottom < clipped.Top && + cell.Top > clipped.Bottom) + return true; + } + + return false; + } + + private static List MergeCells(bool[,] empty, List xList, List yList, int rows, int cols) + { + var used = new bool[rows, cols]; + var results = new List(); + + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + if (!empty[r, c] || used[r, c]) + continue; + + // Expand right as far as possible + var maxC = c; + while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1]) + maxC++; + + // Expand down as far as possible + var maxR = r; + while (maxR + 1 < rows) + { + var rowOk = true; + for (var cc = c; cc <= maxC; cc++) + { + if (!empty[maxR + 1, cc] || used[maxR + 1, cc]) + { + rowOk = false; + break; + } + } + + if (!rowOk) break; + maxR++; + } + + // Mark cells as used + for (var rr = r; rr <= maxR; rr++) + for (var cc = c; cc <= maxC; cc++) + used[rr, cc] = true; + + var box = new Box( + xList[c], yList[r], + xList[maxC + 1] - xList[c], + yList[maxR + 1] - yList[r]); + + results.Add(box); + } + } + + return results; + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal` +Expected: All PASS + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/RemnantFinder.cs +git commit -m "feat: add RemnantFinder with edge projection algorithm" +``` + +--- + +## Chunk 2: FillScore Simplification and Remnant Cleanup + +### Task 3: Simplify FillScore — remove remnant tracking + +**Files:** +- Modify: `OpenNest.Engine/FillScore.cs` + +- [ ] **Step 1: Remove remnant-related members from FillScore** + +Remove `MinRemnantDimension`, `UsableRemnantArea`, `ComputeUsableRemnantArea()`. Simplify constructor and `Compute()`. Update `CompareTo` to compare count then density (no remnant area). + +New `FillScore.cs`: + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + public readonly struct FillScore : System.IComparable + { + public int Count { get; } + + /// + /// Total part area / bounding box area of all placed parts. + /// + public double Density { get; } + + public FillScore(int count, double density) + { + Count = count; + Density = density; + } + + /// + /// Computes a fill score from placed parts and the work area they were placed in. + /// + public static FillScore Compute(List parts, Box workArea) + { + if (parts == null || parts.Count == 0) + return default; + + var totalPartArea = 0.0; + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + + foreach (var part in parts) + { + totalPartArea += part.BaseDrawing.Area; + var bb = part.BoundingBox; + + if (bb.Left < minX) minX = bb.Left; + if (bb.Bottom < minY) minY = bb.Bottom; + if (bb.Right > maxX) maxX = bb.Right; + if (bb.Top > maxY) maxY = bb.Top; + } + + var bboxArea = (maxX - minX) * (maxY - minY); + var density = bboxArea > 0 ? totalPartArea / bboxArea : 0; + + return new FillScore(parts.Count, density); + } + + /// + /// Lexicographic comparison: count, then density. + /// + public int CompareTo(FillScore other) + { + var c = Count.CompareTo(other.Count); + + if (c != 0) + return c; + + return Density.CompareTo(other.Density); + } + + public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0; + public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0; + public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0; + public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0; + } +} +``` + +- [ ] **Step 2: Commit (build will not pass yet — remaining UsableRemnantArea references fixed in Tasks 4-5)** + +```bash +git add OpenNest.Engine/FillScore.cs +git commit -m "refactor: simplify FillScore to count + density, remove remnant tracking" +``` + +### Task 4: Update DefaultNestEngine debug logging + +**Files:** +- Modify: `OpenNest.Engine/DefaultNestEngine.cs:456-459` + +- [ ] **Step 1: Update FillWithPairs debug log** + +At line 456, change: +```csharp +Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); +``` +to: +```csharp +Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}"); +``` + +Also update the file-based debug log at lines 457-459 — change `bestScore.UsableRemnantArea` references similarly. If the file log references `UsableRemnantArea`, remove that interpolation. + +- [ ] **Step 2: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs +git commit -m "fix: update FillWithPairs debug logging after FillScore simplification" +``` + +### Task 5: Remove NestProgress.UsableRemnantArea and UI references + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs:44` +- Modify: `OpenNest.Engine/NestEngineBase.cs:232` +- Modify: `OpenNest\Forms\NestProgressForm.cs:40` + +- [ ] **Step 1: Remove UsableRemnantArea from NestProgress** + +In `NestProgress.cs`, remove line 44: +```csharp +public double UsableRemnantArea { get; set; } +``` + +- [ ] **Step 2: Remove UsableRemnantArea from ReportProgress** + +In `NestEngineBase.cs` at line 232, remove: +```csharp +UsableRemnantArea = workArea.Area() - totalPartArea, +``` + +- [ ] **Step 3: Remove remnant display from NestProgressForm** + +In `NestProgressForm.cs` at line 40, remove: +```csharp +remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; +``` + +Also remove the `remnantValue` label and its corresponding "Remnant:" label from the form's Designer file (or set them to display something else if desired). If simpler, just remove the line that sets the text — the label will remain but show its default empty text. + +- [ ] **Step 4: Build to verify all UsableRemnantArea references are resolved** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds — all `UsableRemnantArea` references are now removed + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs +git commit -m "refactor: remove UsableRemnantArea from NestProgress and UI" +``` + +--- + +## Chunk 3: Remove Remainder Phase from Engine + +### Task 6: Remove remainder phase from DefaultNestEngine + +**Files:** +- Modify: `OpenNest.Engine/DefaultNestEngine.cs` + +- [ ] **Step 1: Remove TryRemainderImprovement calls from Fill() overrides** + +In the first `Fill()` override (line 31), remove lines 40-53 (the remainder improvement block after `FindBestFill`): +```csharp +// Remove this entire block: +if (!token.IsCancellationRequested) +{ + var remainderSw = Stopwatch.StartNew(); + var improved = TryRemainderImprovement(item, workArea, best); + // ... through to the closing brace +} +``` + +In the second `Fill()` override (line 118), remove lines 165-174 (the remainder improvement block inside the `if (groupParts.Count == 1)` block): +```csharp +// Remove this entire block: +var improved = TryRemainderImprovement(nestItem, workArea, best); +if (IsBetterFill(improved, best, workArea)) +{ + // ... +} +``` + +- [ ] **Step 2: Remove TryRemainderImprovement, TryStripRefill, ClusterParts methods** + +Remove the three private methods (lines 563-694): +- `TryRemainderImprovement` +- `TryStripRefill` +- `ClusterParts` + +- [ ] **Step 3: Update Description property** + +Change: +```csharp +public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"; +``` +to: +```csharp +public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)"; +``` + +- [ ] **Step 4: Build and run tests** + +Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal` +Expected: Build succeeds, tests pass + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs +git commit -m "refactor: remove remainder phase from DefaultNestEngine" +``` + +### Task 7: Remove NestPhase.Remainder and cleanup + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs:11` +- Modify: `OpenNest.Engine/NestEngineBase.cs:314` +- Modify: `OpenNest\Forms\NestProgressForm.cs:100` + +- [ ] **Step 1: Remove Remainder from NestPhase enum** + +In `NestProgress.cs`, remove `Remainder` from the enum. + +- [ ] **Step 2: Remove Remainder case from FormatPhaseName** + +In `NestEngineBase.cs`, remove: +```csharp +case NestPhase.Remainder: return "Remainder"; +``` + +- [ ] **Step 3: Remove Remainder case from FormatPhase** + +In `NestProgressForm.cs`, remove: +```csharp +case NestPhase.Remainder: return "Filling remainder..."; +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs +git commit -m "refactor: remove NestPhase.Remainder enum value and switch cases" +``` + +### Task 8: Remove ComputeRemainderWithin and update Nest() + +**Files:** +- Modify: `OpenNest.Engine/NestEngineBase.cs:92,120-133` + +- [ ] **Step 1: Replace ComputeRemainderWithin usage in Nest()** + +At line 91-92, change: +```csharp +var placedBox = parts.Cast().GetBoundingBox(); +workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing); +``` +to: +```csharp +var placedObstacles = parts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList(); +var finder = new RemnantFinder(workArea, placedObstacles); +var remnants = finder.FindRemnants(); +if (remnants.Count == 0) + break; +workArea = remnants[0]; // Largest remnant +``` + +Note: This is a behavioral improvement — the old code used a single merged bounding box and picked one strip. The new code finds per-part obstacles and discovers all gaps, using the largest. This may produce different (better) results for non-rectangular layouts. + +- [ ] **Step 2: Remove ComputeRemainderWithin method** + +Delete lines 120-133 (the `ComputeRemainderWithin` static method). + +- [ ] **Step 3: Build and run tests** + +Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal` +Expected: Build succeeds, tests pass + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngineBase.cs +git commit -m "refactor: replace ComputeRemainderWithin with RemnantFinder in Nest()" +``` + +--- + +## Chunk 4: Remove Old Remnant Code and Update Callers + +### Task 9: Remove Plate.GetRemnants() + +**Files:** +- Modify: `OpenNest.Core/Plate.cs:477-557` + +- [ ] **Step 1: Remove GetRemnants method** + +Delete the `GetRemnants()` method (lines 477-557, the XML doc comment through the closing brace). + +- [ ] **Step 2: Build to check for remaining references** + +Run: `dotnet build OpenNest.sln` +Expected: Errors in `NestingTools.cs` and `InspectionTools.cs` (fixed in next task) + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Plate.cs +git commit -m "refactor: remove Plate.GetRemnants(), replaced by RemnantFinder" +``` + +### Task 10: Update MCP callers + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs:105` +- Modify: `OpenNest.Mcp/Tools/InspectionTools.cs:31` + +- [ ] **Step 1: Update NestingTools.FillRemnants** + +At line 105, change: +```csharp +var remnants = plate.GetRemnants(); +``` +to: +```csharp +var finder = RemnantFinder.FromPlate(plate); +var remnants = finder.FindRemnants(); +``` + +- [ ] **Step 2: Update InspectionTools.GetPlateInfo** + +At line 31, change: +```csharp +var remnants = plate.GetRemnants(); +``` +to: +```csharp +var remnants = RemnantFinder.FromPlate(plate).FindRemnants(); +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs OpenNest.Mcp/Tools/InspectionTools.cs +git commit -m "refactor: update MCP tools to use RemnantFinder" +``` + +### Task 11: Remove StripNestResult.RemnantBox + +**Files:** +- Modify: `OpenNest.Engine/StripNestResult.cs:10` +- Modify: `OpenNest.Engine/StripNestEngine.cs:301` + +- [ ] **Step 1: Remove RemnantBox property from StripNestResult** + +In `StripNestResult.cs`, remove line 10: +```csharp +public Box RemnantBox { get; set; } +``` + +- [ ] **Step 2: Remove RemnantBox assignment in StripNestEngine** + +In `StripNestEngine.cs` at line 301, remove: +```csharp +result.RemnantBox = remnantBox; +``` + +Also check if the local `remnantBox` variable is now unused — if so, remove its declaration and computation too. + +- [ ] **Step 3: Build and run tests** + +Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal` +Expected: Build succeeds, all tests pass + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripNestEngine.cs +git commit -m "refactor: remove StripNestResult.RemnantBox" +``` + +--- + +## Chunk 5: PlateView Active Work Area Visualization + +### Task 12: Add ActiveWorkArea to NestProgress + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs` + +- [ ] **Step 1: Add ActiveWorkArea property to NestProgress** + +`Box` is a reference type (class), so use `Box` directly (not `Box?`): + +```csharp +public Box ActiveWorkArea { get; set; } +``` + +`NestProgress.cs` already has `using OpenNest.Geometry;` via the `Box` usage in existing properties. If not, add it. + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs +git commit -m "feat: add ActiveWorkArea property to NestProgress" +``` + +### Task 13: Draw active work area on PlateView + +**Files:** +- Modify: `OpenNest\Controls\PlateView.cs` + +- [ ] **Step 1: Add ActiveWorkArea property** + +Add a field and property to `PlateView`: +```csharp +private Box activeWorkArea; + +public Box ActiveWorkArea +{ + get => activeWorkArea; + set + { + activeWorkArea = value; + Invalidate(); + } +} +``` + +- [ ] **Step 2: Add DrawActiveWorkArea method** + +Add a private method to draw the dashed orange rectangle, using the same coordinate transform pattern as `DrawBox` (line 591-601): + +```csharp +private void DrawActiveWorkArea(Graphics g) +{ + if (activeWorkArea == null) + return; + + var rect = new RectangleF + { + Location = PointWorldToGraph(activeWorkArea.Location), + Width = LengthWorldToGui(activeWorkArea.Width), + Height = LengthWorldToGui(activeWorkArea.Length) + }; + rect.Y -= rect.Height; + + using var pen = new Pen(Color.Orange, 2f) + { + DashStyle = DashStyle.Dash + }; + g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); +} +``` + +- [ ] **Step 3: Call DrawActiveWorkArea in OnPaint** + +In `OnPaint` (line 363-364), add the call after `DrawParts`: +```csharp +DrawPlate(e.Graphics); +DrawParts(e.Graphics); +DrawActiveWorkArea(e.Graphics); +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest/Controls/PlateView.cs +git commit -m "feat: draw active work area as dashed orange rectangle on PlateView" +``` + +### Task 14: Wire ActiveWorkArea through progress callbacks + +**Files:** +- Modify: `OpenNest\Controls\PlateView.cs:828-829` +- Modify: `OpenNest\Forms\MainForm.cs:760-761,895-896,955-956` + +The `PlateView` and `MainForm` both have progress callbacks that already set `SetTemporaryParts`. Add `ActiveWorkArea` alongside those. + +- [ ] **Step 1: Update PlateView.FillWithProgress callback** + +At `PlateView.cs` line 828-829, the callback currently does: +```csharp +progressForm.UpdateProgress(p); +SetTemporaryParts(p.BestParts); +``` + +Add after `SetTemporaryParts`: +```csharp +ActiveWorkArea = p.ActiveWorkArea; +``` + +- [ ] **Step 2: Update MainForm progress callbacks** + +There are three progress callback sites in `MainForm.cs`. At each one, after the `SetTemporaryParts` call, add: + +At line 761 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`): +```csharp +activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea; +``` + +At line 896 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`): +```csharp +activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea; +``` + +At line 956 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`): +```csharp +activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea; +``` + +- [ ] **Step 3: Clear ActiveWorkArea when nesting completes** + +In each nesting method's completion/cleanup path, clear the work area overlay. In `PlateView.cs` after the fill task completes (near `progressForm.ShowCompleted()`), add: +```csharp +ActiveWorkArea = null; +``` + +Similarly in each `MainForm` nesting method's completion path: +```csharp +activeForm.PlateView.ActiveWorkArea = null; +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs +git commit -m "feat: wire ActiveWorkArea from NestProgress to PlateView" +``` + +### Task 15: Set ActiveWorkArea in Nest() method + +**Files:** +- Modify: `OpenNest.Engine/NestEngineBase.cs` (the `Nest()` method updated in Task 8) + +- [ ] **Step 1: Report ActiveWorkArea in Nest() progress** + +In the `Nest()` method, after picking the largest remnant as the next work area (Task 8's change), set `ActiveWorkArea` on the progress report. Find the `ReportProgress` call inside or near the fill loop and ensure the progress object carries the current `workArea`. + +The simplest approach: pass the work area through `ReportProgress`. In `NestEngineBase.ReportProgress` (the static helper), add `ActiveWorkArea = workArea` to the `NestProgress` initializer: + +In `ReportProgress`, add to the `new NestProgress { ... }` block: +```csharp +ActiveWorkArea = workArea, +``` + +This ensures every progress report includes the current work area being filled. + +- [ ] **Step 2: Build and run tests** + +Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal` +Expected: Build succeeds, all tests pass + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineBase.cs +git commit -m "feat: report ActiveWorkArea in NestProgress from ReportProgress" +``` + +--- + +## Chunk 6: Final Verification + +### Task 16: Full build and test + +**Files:** None (verification only) + +- [ ] **Step 1: Run full build** + +Run: `dotnet build OpenNest.sln` +Expected: 0 errors, 0 warnings related to remnant code + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test OpenNest.Tests -v minimal` +Expected: All tests pass including new `RemnantFinderTests` + +- [ ] **Step 3: Verify no stale references** + +Run: `grep -rn "GetRemnants\|ComputeRemainderWithin\|TryRemainderImprovement\|MinRemnantDimension\|UsableRemnantArea" --include="*.cs" .` +Expected: No matches in source files (only in docs/specs/plans) + +- [ ] **Step 4: Final commit if any fixups needed** + +```bash +git add -A +git commit -m "chore: final cleanup after remnant finder extraction" +``` diff --git a/docs/superpowers/specs/2026-03-16-remnant-finder-design.md b/docs/superpowers/specs/2026-03-16-remnant-finder-design.md index 22cc97b..10297a7 100644 --- a/docs/superpowers/specs/2026-03-16-remnant-finder-design.md +++ b/docs/superpowers/specs/2026-03-16-remnant-finder-design.md @@ -132,11 +132,11 @@ var remnants = finder.FindRemnants(minDimension); Any caller that previously relied on `TryRemainderImprovement` getting called automatically inside `Fill()` will need to implement the iterative loop: fill -> find remnants -> fill remnant -> repeat. -## PlateView Remnant Visualization +## PlateView Work Area Visualization -When a remnant is being filled (during the iterative workflow), the `PlateView` control should display the active remnant's outline as a dashed rectangle in a contrasting color (orange). The outline persists while that remnant is being filled and disappears when the next remnant starts or the fill completes. +When an area is being filled (during the iterative workflow), the `PlateView` control displays the active work area's outline as a dashed orange rectangle. The outline persists while that area is being filled and disappears when the fill completes. -**Implementation:** Add a `Box? ActiveRemnant` property to `PlateView`. When set, the paint handler draws a dashed rectangle at that location. The caller sets it before filling a remnant and clears it (sets to `null`) when done or moving to the next remnant. The `NestProgress` class gets a new `Box? ActiveRemnant` property so the progress pipeline can carry the current remnant box from the engine caller to the UI. +**Implementation:** Add a `Box ActiveWorkArea` property to `PlateView` (`Box` is a reference type, so `null` means no overlay). When set, the paint handler draws a dashed rectangle at that location. The `NestProgress` class gets a new `Box ActiveWorkArea` property so the progress pipeline carries the current work area from the engine to the UI. The existing progress callbacks in `PlateView.FillWithProgress` and `MainForm` set `PlateView.ActiveWorkArea` from the progress object, alongside the existing `SetTemporaryParts` calls. `NestEngineBase.ReportProgress` populates `ActiveWorkArea` from its `workArea` parameter. ## Future