Files
OpenNest/docs/superpowers/plans/2026-03-13-nesting-progress-window.md
2026-03-13 08:20:52 -04:00

42 KiB

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<Part>-returning overloads that accept IProgress<NestProgress> + 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<T>, 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<Part>-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

// 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<Part> BestParts { get; set; }
    }
}
  • Step 2: Build to verify

Run: dotnet build OpenNest.Engine Expected: Build succeeded

  • Step 3: Commit
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<NestProgress> 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<T>:

private static void ReportProgress(
    IProgress<NestProgress> progress,
    NestPhase phase,
    int plateNumber,
    List<Part> best,
    Box workArea)
{
    if (progress == null || best == null || best.Count == 0)
        return;

    var score = FillScore.Compute(best, workArea);
    var clonedParts = new List<Part>(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):

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
private List<Part> FindBestFill(NestItem item, Box workArea,
    IProgress<NestProgress> progress, CancellationToken token)
{
    List<Part> best = null;

    try
    {
        var bestRotation = RotationAnalysis.FindBestRotation(item);
        var engine = new FillLinear(workArea, Plate.PartSpacing);
        var angles = new List<double> { 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<Part> 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<Part>();
}
  • 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
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:

private List<Part> 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<Part> 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<Part> 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<Part>();
}
  • Step 2: Build to verify

Run: dotnet build OpenNest.Engine Expected: Build succeeded

  • Step 3: Commit
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<Part>-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<Part> 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:

public List<Part> Fill(NestItem item, Box workArea,
    IProgress<NestProgress> progress, CancellationToken token)
{
    var best = FindBestFill(item, workArea, progress, token);

    if (token.IsCancellationRequested)
        return best ?? new List<Part>();

    // 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<Part>();

    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:

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<Part>, Box, IProgress, CancellationToken) overload

Add after the existing Fill(List<Part> groupParts, Box workArea) method (after line 180). Same pattern — compute and return without touching Plate.Parts:

public List<Part> Fill(List<Part> groupParts, Box workArea,
    IProgress<NestProgress> progress, CancellationToken token)
{
    if (groupParts == null || groupParts.Count == 0)
        return new List<Part>();

    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<Part>();
}
  • Step 4: Rewire existing bool Fill(List<Part>, Box) to delegate

Replace the body of Fill(List<Part> groupParts, Box workArea) at line 137:

public bool Fill(List<Part> 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
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat(engine): add public Fill overloads returning List<Part> 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):

private Color previewPartColor;

Add a private Pen and Brush field alongside the existing ones. Find where layoutOutlinePen etc. are declared and add:

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:

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:

PreviewPartColor = Color.FromArgb(255, 140, 0),  // orange
  • Step 3: Build to verify

Run: dotnet build OpenNest Expected: Build succeeded

  • Step 4: Commit
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<LayoutPart>) 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):

private List<LayoutPart> temporaryParts = new List<LayoutPart>();
  • 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:

// 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:

public void SetTemporaryParts(List<Part> parts)
{
    temporaryParts.Clear();

    if (parts != null)
    {
        foreach (var part in parts)
            temporaryParts.Add(LayoutPart.Create(part, this));
    }

    Invalidate();
}
  • Step 4: Add ClearTemporaryParts method
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.

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):

temporaryParts.Clear();
  • Step 7: Update Refresh to include temporary parts

In Refresh() (line 376), add temporary part updates:

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
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
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
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:

private bool nestingInProgress;
private CancellationTokenSource nestingCts;

Add a helper method that extends the existing EnableCheck pattern. Place it after NavigationEnableCheck():

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:

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<NestProgress>(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
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:

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<NestProgress>(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
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:

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<NestProgress> progress;
        private CancellationTokenSource cts;
        private Action<List<Part>> onFillComplete;

        public ActionFillArea(PlateView plateView, Drawing drawing)
            : this(plateView, drawing, null, null, null)
        {
        }

        public ActionFillArea(PlateView plateView, Drawing drawing,
            IProgress<NestProgress> progress, CancellationTokenSource cts,
            Action<List<Part>> 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<Part>());
                }
            }
            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:

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<NestProgress>(p =>
    {
        progressForm.UpdateProgress(p);
        activeForm.PlateView.SetTemporaryParts(p.BestParts);
    });

    Action<List<Part>> 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
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:

// 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
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
git add -A
git commit -m "fix: address issues found during manual testing"