From 4da5e3fc89074225f19cdd0acc04e00154e118f5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 08:20:52 -0400 Subject: [PATCH] docs: add nesting progress window implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-nesting-progress-window.md | 1375 +++++++++++++++++ 1 file changed, 1375 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-nesting-progress-window.md diff --git a/docs/superpowers/plans/2026-03-13-nesting-progress-window.md b/docs/superpowers/plans/2026-03-13-nesting-progress-window.md new file mode 100644 index 0000000..587eac6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-nesting-progress-window.md @@ -0,0 +1,1375 @@ +# Nesting Progress Window 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:** Add a live-preview progress window so users can watch nesting results appear on the plate and stop early if satisfied. + +**Architecture:** The engine gets new `List`-returning overloads that accept `IProgress` + `CancellationToken`. A modeless `NestProgressForm` shows stats. `PlateView` gains a temporary parts list drawn in a preview color. `MainForm` orchestrates everything via `Task.Run` + `await`. + +**Tech Stack:** .NET 8 WinForms, `IProgress`, `CancellationToken`, `Task.Run` + +**Spec:** `docs/superpowers/specs/2026-03-13-nesting-progress-window-design.md` + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `OpenNest.Engine/NestProgress.cs` | **New.** `NestPhase` enum + `NestProgress` class (progress data model) | +| `OpenNest.Engine/NestEngine.cs` | **Modify.** Add `List`-returning overloads with progress/cancellation. Existing `bool` overloads delegate to them. | +| `OpenNest/ColorScheme.cs` | **Modify.** Add `PreviewPartColor` + `PreviewPartPen` + `PreviewPartBrush` | +| `OpenNest/Controls/PlateView.cs` | **Modify.** Add `temporaryParts` list, draw them in preview color, expose `SetTemporaryParts`/`ClearTemporaryParts`/`AcceptTemporaryParts` | +| `OpenNest/Forms/NestProgressForm.cs` | **New.** Modeless dialog with TableLayoutPanel showing stats + Stop button | +| `OpenNest/Forms/MainForm.cs` | **Modify.** Rewire `RunAutoNest_Click` and `FillPlate_Click` to async with progress. Add UI lockout. | +| `OpenNest/Actions/ActionFillArea.cs` | **Modify.** Use progress overload for area fill. | + +--- + +## Chunk 1: Engine Progress Infrastructure + +### Task 1: NestProgress Data Model + +**Files:** +- Create: `OpenNest.Engine/NestProgress.cs` + +- [ ] **Step 1: Create NestPhase enum and NestProgress class** + +```csharp +// OpenNest.Engine/NestProgress.cs +using System.Collections.Generic; + +namespace OpenNest +{ + public enum NestPhase + { + Linear, + RectBestFit, + Pairs, + Remainder + } + + public class NestProgress + { + public NestPhase Phase { get; set; } + public int PlateNumber { get; set; } + public int BestPartCount { get; set; } + public double BestDensity { get; set; } + public double UsableRemnantArea { get; set; } + public List BestParts { get; set; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs +git commit -m "feat(engine): add NestPhase enum and NestProgress data model" +``` + +--- + +### Task 2: NestEngine Progress Overloads — FindBestFill + +This task adds the core progress/cancellation support to `FindBestFill`, the private method that tries Linear → RectBestFit → Pairs → Remainder strategies. The new method signature adds `IProgress` and `CancellationToken` parameters. + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Context:** `FindBestFill` is currently at line 55. It uses `Parallel.ForEach` for the linear phase (line 90) and `FillWithPairs` uses `Parallel.For` (line 224). Both need the cancellation token threaded into `ParallelOptions`. + +- [ ] **Step 1: Add a private helper to report progress** + +Add this helper method at the end of `NestEngine` (before the closing brace at line 536). It clones the best parts list and reports via `IProgress`: + +```csharp +private static void ReportProgress( + IProgress progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea) +{ + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(best.Count); + + foreach (var part in best) + clonedParts.Add((Part)part.Clone()); + + progress.Report(new NestProgress + { + Phase = phase, + PlateNumber = plateNumber, + BestPartCount = score.Count, + BestDensity = score.Density, + UsableRemnantArea = score.UsableRemnantArea, + BestParts = clonedParts + }); +} +``` + +- [ ] **Step 2: Add a `PlateNumber` property to NestEngine** + +Add a public property so callers can set the current plate number for progress reporting. Add after the `NestDirection` property (line 20): + +```csharp +public int PlateNumber { get; set; } +``` + +- [ ] **Step 3: Create `FindBestFill` overload with progress and cancellation** + +Add a new private method below the existing `FindBestFill` (after line 135). This is a copy of `FindBestFill` with progress reporting and cancellation token support injected at each phase boundary. Key changes: + +- `Parallel.ForEach` gets `new ParallelOptions { CancellationToken = token }` +- After each phase (linear, rect, pairs), call `ReportProgress(...)` if a new best is found +- After each phase, call `token.ThrowIfCancellationRequested()` +- The whole method is wrapped in try/catch for `OperationCanceledException` to return current best + +```csharp +private List FindBestFill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +{ + List best = null; + + try + { + var bestRotation = RotationAnalysis.FindBestRotation(item); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + + 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.Length); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); + + if (workAreaShortSide < partLongestSide) + { + 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); + } + } + + // Linear phase + var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + + System.Threading.Tasks.Parallel.ForEach(angles, + new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, + angle => + { + var localEngine = new FillLinear(workArea, Plate.PartSpacing); + var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); + var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); + + if (h != null && h.Count > 0) + linearBag.Add((FillScore.Compute(h, workArea), h)); + if (v != null && v.Count > 0) + linearBag.Add((FillScore.Compute(v, workArea), v)); + }); + + var bestScore = default(FillScore); + + foreach (var (score, parts) in linearBag) + { + if (best == null || score > bestScore) + { + best = parts; + bestScore = score; + } + } + + var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; + Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); + token.ThrowIfCancellationRequested(); + + // RectBestFit phase + var rectResult = FillRectangleBestFit(item, workArea); + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best, workArea)) + { + best = rectResult; + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); + } + + token.ThrowIfCancellationRequested(); + + // Pairs phase + var pairResult = FillWithPairs(item, workArea, token); + Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best, workArea)) + { + best = pairResult; + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[FindBestFill] Cancelled, returning current best"); + } + + return best ?? new List(); +} +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded (the new overload exists but isn't called yet) + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add FindBestFill overload with progress and cancellation" +``` + +--- + +### Task 3: NestEngine Progress Overloads — FillWithPairs + +Thread the `CancellationToken` into `FillWithPairs` so its `Parallel.For` loop can be cancelled mid-phase. + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Context:** `FillWithPairs` is at line 213. It uses `Parallel.For` at line 224. + +- [ ] **Step 1: Add FillWithPairs overload accepting CancellationToken** + +Add a new private method below the existing `FillWithPairs` (after line 250). This is a copy with `ParallelOptions` added to the `Parallel.For` call: + +```csharp +private List FillWithPairs(NestItem item, Box workArea, CancellationToken token) +{ + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, Plate.Size.Width, Plate.Size.Length, + Plate.PartSpacing); + + var candidates = SelectPairCandidates(bestFits, workArea); + Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + + var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + + try + { + System.Threading.Tasks.Parallel.For(0, candidates.Count, + new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, + i => + { + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var filled = FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) + resultBag.Add((FillScore.Compute(filled, workArea), filled)); + }); + } + catch (OperationCanceledException) + { + Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); + } + + List best = null; + var bestScore = default(FillScore); + + foreach (var (score, parts) in resultBag) + { + if (best == null || score > bestScore) + { + best = parts; + bestScore = score; + } + } + + Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); + return best ?? new List(); +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add FillWithPairs overload with CancellationToken" +``` + +--- + +### Task 4: NestEngine Public Fill Overloads + +Add the public `List`-returning `Fill` overloads that callers (MainForm) will use. These call `FindBestFill` with progress/cancellation and return the result without adding to `Plate.Parts`. Wire the existing `bool Fill(...)` overloads to delegate to them. + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +**Context:** The existing `bool Fill(NestItem item, Box workArea)` is at line 32. The existing `bool Fill(List groupParts, Box workArea)` is at line 137. + +- [ ] **Step 1: Add public Fill(NestItem, Box, IProgress, CancellationToken) overload** + +Add after the existing `Fill(NestItem, Box)` method (after line 53). This calls `FindBestFill` with progress/cancellation, applies `TryRemainderImprovement`, and returns the parts without adding to the plate: + +```csharp +public List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +{ + var best = FindBestFill(item, workArea, progress, token); + + if (token.IsCancellationRequested) + return best ?? new List(); + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(item, workArea, best); + + if (IsBetterFill(improved, best, workArea)) + { + Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); + } + + if (best == null || best.Count == 0) + return new List(); + + if (item.Quantity > 0 && best.Count > item.Quantity) + best = best.Take(item.Quantity).ToList(); + + return best; +} +``` + +- [ ] **Step 2: Rewire existing bool Fill(NestItem, Box) to delegate** + +Replace the body of `Fill(NestItem item, Box workArea)` at line 32 to delegate to the new overload: + +```csharp +public bool Fill(NestItem item, Box workArea) +{ + var parts = Fill(item, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; +} +``` + +Add `using System.Threading;` to the top of the file if not already present. + +- [ ] **Step 3: Add public Fill(List\, Box, IProgress, CancellationToken) overload** + +Add after the existing `Fill(List groupParts, Box workArea)` method (after line 180). Same pattern — compute and return without touching `Plate.Parts`: + +```csharp +public List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) +{ + if (groupParts == null || groupParts.Count == 0) + return new List(); + + var engine = new FillLinear(workArea, Plate.PartSpacing); + var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); + var best = FillPattern(engine, groupParts, angles, workArea); + + Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); + + if (groupParts.Count == 1) + { + try + { + token.ThrowIfCancellationRequested(); + + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + var rectResult = FillRectangleBestFit(nestItem, workArea); + + Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best, workArea)) + { + best = rectResult; + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); + } + + token.ThrowIfCancellationRequested(); + + var pairResult = FillWithPairs(nestItem, workArea, token); + + Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best, workArea)) + { + best = pairResult; + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + } + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(nestItem, workArea, best); + + if (IsBetterFill(improved, best, workArea)) + { + Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); + } + } + + return best ?? new List(); +} +``` + +- [ ] **Step 4: Rewire existing bool Fill(List\, Box) to delegate** + +Replace the body of `Fill(List groupParts, Box workArea)` at line 137: + +```csharp +public bool Fill(List groupParts, Box workArea) +{ + var parts = Fill(groupParts, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; +} +``` + +- [ ] **Step 5: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded (full solution — ensures MCP and UI still compile) + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add public Fill overloads returning List with progress/cancellation" +``` + +--- + +## Chunk 2: PlateView Temporary Parts + +### Task 5: Add PreviewPart Color to ColorScheme + +**Files:** +- Modify: `OpenNest/ColorScheme.cs` + +**Context:** Colors follow a pattern: private backing field, public property with getter/setter that lazily creates Pen/Brush. See `LayoutOutlineColor` at line 46 for the pattern. The `Default` static instance is at line 15. + +- [ ] **Step 1: Add PreviewPart color fields and properties** + +Add a private field after the existing color fields (after line 13): + +```csharp +private Color previewPartColor; +``` + +Add a private `Pen` and `Brush` field alongside the existing ones. Find where `layoutOutlinePen` etc. are declared and add: + +```csharp +private Pen previewPartPen; +private Brush previewPartBrush; +``` + +Add the `PreviewPartColor` property after the last color property (after `EdgeSpacingColor`, around line 136). Follow the same pattern as `LayoutOutlineColor`: + +```csharp +public Color PreviewPartColor +{ + get { return previewPartColor; } + set + { + previewPartColor = value; + + if (previewPartPen != null) + previewPartPen.Dispose(); + + if (previewPartBrush != null) + previewPartBrush.Dispose(); + + previewPartPen = new Pen(value, 1); + previewPartBrush = new SolidBrush(Color.FromArgb(60, value)); + } +} + +public Pen PreviewPartPen => previewPartPen; +public Brush PreviewPartBrush => previewPartBrush; +``` + +Note: The brush uses `Color.FromArgb(60, value)` for semi-transparency so parts look like a preview overlay, not solid placed parts. + +- [ ] **Step 2: Set default preview color** + +In the `Default` static property initializer (around line 15), add: + +```csharp +PreviewPartColor = Color.FromArgb(255, 140, 0), // orange +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest/ColorScheme.cs +git commit -m "feat(ui): add PreviewPart color to ColorScheme" +``` + +--- + +### Task 6: PlateView Temporary Parts List and Drawing + +**Files:** +- Modify: `OpenNest/Controls/PlateView.cs` + +**Context:** +- `parts` field (private `List`) at line ~27 +- `DrawParts` method at line 453 +- `LayoutPart.Create(Part, PlateView)` is the factory for wrapping Part → LayoutPart +- `Refresh()` at line 376 calls `parts.ForEach(p => p.Update(this))` then `Invalidate()` +- `SetPlate` at line 116 clears `parts` when switching plates + +- [ ] **Step 1: Add temporaryParts field** + +Add after the `parts` field declaration (around line 27): + +```csharp +private List temporaryParts = new List(); +``` + +- [ ] **Step 2: Draw temporary parts in DrawParts** + +In `DrawParts` (line 453), after the main part drawing loop (after line 470 where `part.Draw(g, ...)` is called for regular parts), add temporary part drawing before the `DrawOffset`/`DrawBounds`/`DrawRapid` section: + +```csharp +// Draw temporary (preview) parts +for (var i = 0; i < temporaryParts.Count; i++) +{ + var temp = temporaryParts[i]; + + if (temp.IsDirty) + temp.Update(this); + + var path = temp.Path; + var pathBounds = path.GetBounds(); + + if (!pathBounds.IntersectsWith(viewBounds)) + continue; + + g.FillPath(ColorScheme.PreviewPartBrush, path); + g.DrawPath(ColorScheme.PreviewPartPen, path); +} +``` + +- [ ] **Step 3: Add SetTemporaryParts method** + +Add as a public method on `PlateView`: + +```csharp +public void SetTemporaryParts(List parts) +{ + temporaryParts.Clear(); + + if (parts != null) + { + foreach (var part in parts) + temporaryParts.Add(LayoutPart.Create(part, this)); + } + + Invalidate(); +} +``` + +- [ ] **Step 4: Add ClearTemporaryParts method** + +```csharp +public void ClearTemporaryParts() +{ + temporaryParts.Clear(); + Invalidate(); +} +``` + +- [ ] **Step 5: Add AcceptTemporaryParts method** + +This moves temporary parts into the real plate. It returns the count of parts accepted so the caller can use it for quantity tracking. + +```csharp +public int AcceptTemporaryParts() +{ + var count = temporaryParts.Count; + + foreach (var layoutPart in temporaryParts) + Plate.Parts.Add(layoutPart.BasePart); + + temporaryParts.Clear(); + return count; +} +``` + +Note: `Plate.Parts.Add()` fires `PartAdded` on `ObservableList`, which triggers `plate_PartAdded` on PlateView, which adds a new `LayoutPart` to the regular `parts` list. So the part transitions from temp → real automatically. + +- [ ] **Step 6: Clear temporary parts on plate switch** + +In `SetPlate` (line 116), add `temporaryParts.Clear()` in the cleanup section (after `parts.Clear()` at line 123): + +```csharp +temporaryParts.Clear(); +``` + +- [ ] **Step 7: Update Refresh to include temporary parts** + +In `Refresh()` (line 376), add temporary part updates: + +```csharp +public override void Refresh() +{ + parts.ForEach(p => p.Update(this)); + temporaryParts.ForEach(p => p.Update(this)); + Invalidate(); +} +``` + +- [ ] **Step 8: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 9: Commit** + +```bash +git add OpenNest/Controls/PlateView.cs +git commit -m "feat(ui): add temporary parts list to PlateView with preview drawing" +``` + +--- + +## Chunk 3: NestProgressForm + +### Task 7: Create NestProgressForm + +**Files:** +- Create: `OpenNest/Forms/NestProgressForm.cs` + +**Context:** This is a WinForms form. Since there's no designer in the CLI workflow, build it in code. The form is small and simple — a `TableLayoutPanel` with labels and a Stop button. + +- [ ] **Step 1: Create NestProgressForm.cs** + +```csharp +using System; +using System.Drawing; +using System.Threading; +using System.Windows.Forms; + +namespace OpenNest.Forms +{ + public class NestProgressForm : Form + { + private readonly CancellationTokenSource cts; + + private Label phaseValue; + private Label plateValue; + private Label partsValue; + private Label densityValue; + private Label remnantValue; + private Label plateLabel; + private Button stopButton; + private TableLayoutPanel table; + + public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true) + { + this.cts = cts; + InitializeLayout(showPlateRow); + } + + private void InitializeLayout(bool showPlateRow) + { + Text = "Nesting Progress"; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + StartPosition = FormStartPosition.CenterParent; + ShowInTaskbar = false; + MinimizeBox = false; + MaximizeBox = false; + Size = new Size(280, showPlateRow ? 210 : 190); + + table = new TableLayoutPanel + { + ColumnCount = 2, + Dock = DockStyle.Top, + AutoSize = true, + Padding = new Padding(8) + }; + + table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 80)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + + phaseValue = AddRow(table, "Phase:"); + plateValue = AddRow(table, "Plate:"); + partsValue = AddRow(table, "Parts:"); + densityValue = AddRow(table, "Density:"); + remnantValue = AddRow(table, "Remnant:"); + + if (!showPlateRow) + { + plateLabel = FindLabel(table, "Plate:"); + if (plateLabel != null) + SetRowVisible(plateLabel, plateValue, false); + } + + stopButton = new Button + { + Text = "Stop", + Width = 80, + Anchor = AnchorStyles.None, + Margin = new Padding(0, 8, 0, 8) + }; + + stopButton.Click += StopButton_Click; + + var buttonPanel = new FlowLayoutPanel + { + FlowDirection = FlowDirection.RightToLeft, + Dock = DockStyle.Top, + AutoSize = true, + Padding = new Padding(8, 0, 8, 0) + }; + + buttonPanel.Controls.Add(stopButton); + + Controls.Add(buttonPanel); + Controls.Add(table); + + // Reverse order since Dock.Top stacks bottom-up + Controls.SetChildIndex(table, 0); + Controls.SetChildIndex(buttonPanel, 1); + } + + private Label AddRow(TableLayoutPanel table, string labelText) + { + var row = table.RowCount; + table.RowCount = row + 1; + table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var label = new Label + { + Text = labelText, + Font = new Font(Font, FontStyle.Bold), + AutoSize = true, + Margin = new Padding(4) + }; + + var value = new Label + { + Text = "—", + AutoSize = true, + Margin = new Padding(4) + }; + + table.Controls.Add(label, 0, row); + table.Controls.Add(value, 1, row); + + return value; + } + + private Label FindLabel(TableLayoutPanel table, string text) + { + foreach (Control c in table.Controls) + { + if (c is Label l && l.Text == text) + return l; + } + + return null; + } + + private void SetRowVisible(Label label, Label value, bool visible) + { + label.Visible = visible; + value.Visible = visible; + } + + public void UpdateProgress(NestProgress progress) + { + if (IsDisposed || !IsHandleCreated) + return; + + phaseValue.Text = FormatPhase(progress.Phase); + plateValue.Text = progress.PlateNumber.ToString(); + partsValue.Text = progress.BestPartCount.ToString(); + densityValue.Text = progress.BestDensity.ToString("P1"); + remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; + } + + public void ShowCompleted() + { + if (IsDisposed || !IsHandleCreated) + return; + + phaseValue.Text = "Done"; + stopButton.Text = "Close"; + stopButton.Enabled = true; + stopButton.Click -= StopButton_Click; + stopButton.Click += (s, e) => Close(); + } + + private void StopButton_Click(object sender, EventArgs e) + { + cts.Cancel(); + stopButton.Text = "Stopping..."; + stopButton.Enabled = false; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (!cts.IsCancellationRequested) + cts.Cancel(); + + base.OnFormClosing(e); + } + + private static string FormatPhase(NestPhase phase) + { + switch (phase) + { + case NestPhase.Linear: return "Trying rotations..."; + case NestPhase.RectBestFit: return "Trying best fit..."; + case NestPhase.Pairs: return "Trying pairs..."; + case NestPhase.Remainder: return "Filling remainder..."; + default: return phase.ToString(); + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest/Forms/NestProgressForm.cs +git commit -m "feat(ui): add NestProgressForm modeless dialog" +``` + +--- + +## Chunk 4: MainForm Integration + +### Task 8: Async RunAutoNest_Click + +**Files:** +- Modify: `OpenNest/Forms/MainForm.cs` + +**Context:** `RunAutoNest_Click` is at line 692. It currently runs the nesting loop synchronously. The existing `EnableCheck()` pattern at line 107 is used for menu enable/disable. + +- [ ] **Step 1: Add a nesting-in-progress flag and lockout helper** + +Add fields near the top of `MainForm`: + +```csharp +private bool nestingInProgress; +private CancellationTokenSource nestingCts; +``` + +Add a helper method that extends the existing `EnableCheck` pattern. Place it after `NavigationEnableCheck()`: + +```csharp +private void SetNestingLockout(bool locked) +{ + nestingInProgress = locked; + + // Disable nesting-related menus while running + mnuNest.Enabled = !locked; + mnuPlate.Enabled = !locked; + + // Lock plate navigation + mnuNestPreviousPlate.Enabled = !locked && activeForm != null && !activeForm.IsFirstPlate(); + btnPreviousPlate.Enabled = mnuNestPreviousPlate.Enabled; + mnuNestNextPlate.Enabled = !locked && activeForm != null && !activeForm.IsLastPlate(); + btnNextPlate.Enabled = mnuNestNextPlate.Enabled; + mnuNestFirstPlate.Enabled = !locked && activeForm != null && activeForm.PlateCount > 0 && !activeForm.IsFirstPlate(); + btnFirstPlate.Enabled = mnuNestFirstPlate.Enabled; + mnuNestLastPlate.Enabled = !locked && activeForm != null && activeForm.PlateCount > 0 && !activeForm.IsLastPlate(); + btnLastPlate.Enabled = mnuNestLastPlate.Enabled; +} +``` + +- [ ] **Step 2: Replace RunAutoNest_Click with async version** + +Replace the existing `RunAutoNest_Click` method (lines 692-738) with: + +```csharp +private async void RunAutoNest_Click(object sender, EventArgs e) +{ + var form = new AutoNestForm(activeForm.Nest); + form.AllowPlateCreation = true; + + if (form.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + var items = form.GetNestItems(); + + if (!items.Any(it => it.Quantity > 0)) + return; + + nestingCts = new CancellationTokenSource(); + var token = nestingCts.Token; + + var progressForm = new NestProgressForm(nestingCts, showPlateRow: true); + var plateNumber = 1; + + var progress = new Progress(p => + { + progressForm.UpdateProgress(p); + activeForm.PlateView.SetTemporaryParts(p.BestParts); + }); + + progressForm.Show(this); + SetNestingLockout(true); + + try + { + while (items.Any(it => it.Quantity > 0)) + { + if (token.IsCancellationRequested) + break; + + var plate = activeForm.PlateView.Plate.Parts.Count > 0 + ? activeForm.Nest.CreatePlate() + : activeForm.PlateView.Plate; + + // If a new plate was created, switch to it + if (plate != activeForm.PlateView.Plate) + activeForm.LoadLastPlate(); + + var engine = new NestEngine(plate) { PlateNumber = plateNumber }; + var filled = false; + + foreach (var item in items) + { + if (item.Quantity <= 0) + continue; + + if (token.IsCancellationRequested) + break; + + // Run the engine on a background thread + var parts = await Task.Run(() => + engine.Fill(item, plate.WorkArea(), progress, token)); + + if (parts.Count == 0) + continue; + + filled = true; + + // Count parts per drawing before accepting (for quantity tracking) + foreach (var group in parts.GroupBy(p => p.BaseDrawing)) + { + var placed = group.Count(); + + foreach (var ni in items) + { + if (ni.Drawing == group.Key) + ni.Quantity -= placed; + } + } + + // Accept the preview parts into the real plate + activeForm.PlateView.AcceptTemporaryParts(); + } + + if (!filled) + break; + + plateNumber++; + } + + activeForm.Nest.UpdateDrawingQuantities(); + progressForm.ShowCompleted(); + } + catch (Exception ex) + { + activeForm.PlateView.ClearTemporaryParts(); + MessageBox.Show($"Nesting error: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + progressForm.Close(); + SetNestingLockout(false); + nestingCts.Dispose(); + nestingCts = null; + } +} +``` + +Add `using System.Threading.Tasks;` and `using System.Threading;` to the top of MainForm.cs if not already present. + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest/Forms/MainForm.cs +git commit -m "feat(ui): make RunAutoNest_Click async with progress and cancellation" +``` + +--- + +### Task 9: Async FillPlate_Click + +**Files:** +- Modify: `OpenNest/Forms/MainForm.cs` + +**Context:** `FillPlate_Click` is at line 785. Currently creates an engine and calls `Fill` synchronously. This should also show progress since a single-plate fill can be slow. + +- [ ] **Step 1: Replace FillPlate_Click with async version** + +Replace the existing `FillPlate_Click` method (lines 785-808) with: + +```csharp +private async void FillPlate_Click(object sender, EventArgs e) +{ + if (activeForm == null) + return; + + if (activeForm.Nest.Drawings.Count == 0) + return; + + var form = new FillPlateForm(activeForm.Nest.Drawings); + form.ShowDialog(); + + var drawing = form.SelectedDrawing; + + if (drawing == null) + return; + + nestingCts = new CancellationTokenSource(); + var token = nestingCts.Token; + + var progressForm = new NestProgressForm(nestingCts, showPlateRow: false); + + var progress = new Progress(p => + { + progressForm.UpdateProgress(p); + activeForm.PlateView.SetTemporaryParts(p.BestParts); + }); + + progressForm.Show(this); + SetNestingLockout(true); + + try + { + var plate = activeForm.PlateView.Plate; + var engine = new NestEngine(plate); + + var parts = await Task.Run(() => + engine.Fill(new NestItem { Drawing = drawing }, + plate.WorkArea(), progress, token)); + + if (parts.Count > 0) + activeForm.PlateView.AcceptTemporaryParts(); + else + activeForm.PlateView.ClearTemporaryParts(); + + progressForm.ShowCompleted(); + } + catch (Exception ex) + { + activeForm.PlateView.ClearTemporaryParts(); + MessageBox.Show($"Nesting error: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + progressForm.Close(); + SetNestingLockout(false); + nestingCts.Dispose(); + nestingCts = null; + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest/Forms/MainForm.cs +git commit -m "feat(ui): make FillPlate_Click async with progress and cancellation" +``` + +--- + +### Task 10: ActionFillArea with Progress + +**Files:** +- Modify: `OpenNest/Actions/ActionFillArea.cs` +- Modify: `OpenNest/Forms/MainForm.cs` + +**Context:** `ActionFillArea` at `OpenNest/Actions/ActionFillArea.cs` currently creates an engine and calls `Fill` synchronously in its `FillArea()` method (line 28). Per the spec, the progress form is owned by MainForm, not ActionFillArea. ActionFillArea needs access to the progress/token infrastructure. + +The cleanest approach: add an event on ActionFillArea that MainForm listens to, letting MainForm handle the async orchestration. But that's overengineered for one call site. Instead, ActionFillArea can accept progress/token via its constructor and use the same async pattern. + +- [ ] **Step 1: Modify ActionFillArea to accept progress and cancellation** + +Replace the entire `ActionFillArea.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using OpenNest.Controls; + +namespace OpenNest.Actions +{ + [DisplayName("Fill Area")] + public class ActionFillArea : ActionSelectArea + { + private Drawing drawing; + private IProgress progress; + private CancellationTokenSource cts; + private Action> onFillComplete; + + public ActionFillArea(PlateView plateView, Drawing drawing) + : this(plateView, drawing, null, null, null) + { + } + + public ActionFillArea(PlateView plateView, Drawing drawing, + IProgress progress, CancellationTokenSource cts, + Action> onFillComplete) + : base(plateView) + { + plateView.PreviewKeyDown += plateView_PreviewKeyDown; + this.drawing = drawing; + this.progress = progress; + this.cts = cts; + this.onFillComplete = onFillComplete; + } + + private void plateView_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) + { + if (e.KeyCode == Keys.Enter) + FillArea(); + else if (e.KeyCode == Keys.Escape && cts != null) + cts.Cancel(); + } + + private async void FillArea() + { + if (progress != null && cts != null) + { + try + { + var engine = new NestEngine(plateView.Plate); + var parts = await Task.Run(() => + engine.Fill(new NestItem { Drawing = drawing }, + SelectedArea, progress, cts.Token)); + + onFillComplete?.Invoke(parts); + } + catch (Exception) + { + onFillComplete?.Invoke(new List()); + } + } + else + { + var engine = new NestEngine(plateView.Plate); + engine.Fill(new NestItem { Drawing = drawing }, SelectedArea); + plateView.Invalidate(); + } + + Update(); + } + + public override void DisconnectEvents() + { + plateView.PreviewKeyDown -= plateView_PreviewKeyDown; + base.DisconnectEvents(); + } + } +} +``` + +- [ ] **Step 2: Update FillArea_Click in MainForm to provide progress infrastructure** + +Replace `FillArea_Click` (line 810) in MainForm: + +```csharp +private void FillArea_Click(object sender, EventArgs e) +{ + if (activeForm == null) + return; + + if (activeForm.Nest.Drawings.Count == 0) + return; + + var form = new FillPlateForm(activeForm.Nest.Drawings); + form.ShowDialog(); + + var drawing = form.SelectedDrawing; + + if (drawing == null) + return; + + nestingCts = new CancellationTokenSource(); + + var progressForm = new NestProgressForm(nestingCts, showPlateRow: false); + + var progress = new Progress(p => + { + progressForm.UpdateProgress(p); + activeForm.PlateView.SetTemporaryParts(p.BestParts); + }); + + Action> onComplete = parts => + { + if (parts != null && parts.Count > 0) + activeForm.PlateView.AcceptTemporaryParts(); + else + activeForm.PlateView.ClearTemporaryParts(); + + progressForm.Close(); + SetNestingLockout(false); + nestingCts.Dispose(); + nestingCts = null; + }; + + progressForm.Show(this); + SetNestingLockout(true); + + activeForm.PlateView.SetAction(typeof(ActionFillArea), + drawing, progress, nestingCts, onComplete); +} +``` + +- [ ] **Step 3: Verify PlateView.SetAction passes extra args to constructor** + +Check that `PlateView.SetAction(Type type, params object[] args)` passes args to the action constructor via reflection. Read the method to confirm it uses `Activator.CreateInstance` or similar with the extra params. If it prepends `PlateView` as the first arg, the constructor signature `(PlateView, Drawing, IProgress, CancellationTokenSource, Action)` should work with `args = [drawing, progress, nestingCts, onComplete]`. + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest/Actions/ActionFillArea.cs OpenNest/Forms/MainForm.cs +git commit -m "feat(ui): add progress support to ActionFillArea and FillArea_Click" +``` + +--- + +## Chunk 5: Edge Cases and Cleanup + +### Task 11: Handle EditNestForm Close During Nesting + +**Files:** +- Modify: `OpenNest/Forms/MainForm.cs` + +**Context:** Per the spec, if the user closes the MDI child while nesting runs, cancel and close the progress form. `MainForm` already tracks `activeForm` and has an `OnMdiChildActivate` handler. + +- [ ] **Step 1: Cancel nesting on MDI child close** + +Find `OnMdiChildActivate` in MainForm (around line 290). Add a check when the active form changes or is set to null: + +In the section where `activeForm` is set, add: + +```csharp +// If nesting is in progress and the active form changed, cancel nesting +if (nestingInProgress && nestingCts != null) +{ + nestingCts.Cancel(); +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest/Forms/MainForm.cs +git commit -m "fix(ui): cancel nesting when MDI child form is closed" +``` + +--- + +### Task 12: Final Build and Manual Test + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded, 0 warnings (or only pre-existing warnings) + +- [ ] **Step 2: Manual smoke test checklist** + +Launch the app and verify: + +1. Open a nest with drawings +2. **Auto Nest** → shows progress form, parts appear on plate in preview color, stats update, Stop works, accepting gives real parts +3. **Fill Plate** → same as above for single plate +4. **Fill Area** → select area, press Enter, progress shows, Stop works +5. **Close MDI child during nesting** → nesting cancels cleanly +6. **Let nesting complete naturally** → progress form shows "Done", parts committed +7. **MCP tools still work** → the `bool Fill(...)` overloads are unchanged + +- [ ] **Step 3: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: address issues found during manual testing" +```