Compare commits

...

15 Commits

Author SHA1 Message Date
aj 986d298786 feat(ui): replace random part colors with curated palette
Avoids visual confusion with reserved UI colors (orange preview parts,
green/blue selection windows) by using a fixed 12-color palette that
skips those hue zones. Removes unused HSLColor and RandomColorGenerator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:45:28 -04:00
aj 9773449563 feat(ui): add FillWithProgress to PlateView, use from ActionClone
Moves async fill+progress orchestration into PlateView so ActionClone's
Ctrl+F fill shows the NestProgressForm dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:39:30 -04:00
aj fde285484a fix(ui): cancel nesting when MDI child form is closed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:33:43 -04:00
aj 98c4c88fc3 feat(ui): add progress support to ActionFillArea and FillArea_Click
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:33:09 -04:00
aj 09fef203df feat(ui): make FillPlate_Click async with progress and cancellation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:32:17 -04:00
aj 0bf128b3e7 feat(ui): make RunAutoNest_Click async with progress and cancellation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:31:31 -04:00
aj af0748fb1b feat(ui): add NestProgressForm modeless dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:30:22 -04:00
aj c545f91d28 feat(ui): add temporary parts list to PlateView with preview drawing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:29:30 -04:00
aj bd2b5ec1db feat(ui): add PreviewPart color to ColorScheme
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:28:18 -04:00
aj ca685ac8ba feat(engine): add public Fill overloads returning List<Part> with progress/cancellation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:27:31 -04:00
aj 93fbe1a9f8 feat(engine): add FindBestFill and FillWithPairs overloads with progress and cancellation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:26:32 -04:00
aj a97477fcdf feat(engine): add NestPhase enum and NestProgress data model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:24:55 -04:00
aj 4da5e3fc89 docs: add nesting progress window implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:20:52 -04:00
aj 5f72f5712e docs: address spec review feedback for nesting progress window
Clarify engine return type ownership, cancellation propagation into
parallel loops, quantity decrement sequencing, conditional phase
behavior, plate navigation lockout, and MDI child close handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:08:14 -04:00
aj 5f64130b9d docs: add nesting progress window design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:05:11 -04:00
11 changed files with 2355 additions and 238 deletions
+245 -31
View File
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -19,6 +21,8 @@ namespace OpenNest
public NestDirection NestDirection { get; set; }
public int PlateNumber { get; set; }
public bool Fill(NestItem item)
{
return Fill(item, Plate.WorkArea());
@@ -31,7 +35,22 @@ namespace OpenNest
public bool Fill(NestItem item, Box workArea)
{
var best = FindBestFill(item, workArea);
var parts = Fill(item, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
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);
@@ -40,16 +59,16 @@ namespace OpenNest
{
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 false;
return new List<Part>();
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
Plate.Parts.AddRange(best);
return true;
return best;
}
private List<Part> FindBestFill(NestItem item, Box workArea)
@@ -134,49 +153,170 @@ namespace OpenNest
return 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>();
}
public bool Fill(List<Part> groupParts, Box workArea)
{
if (groupParts == null || groupParts.Count == 0)
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
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)
{
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;
var pairResult = FillWithPairs(nestItem, workArea);
Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
if (IsBetterFill(pairResult, best, workArea))
best = pairResult;
// Try improving by filling the remainder strip separately.
var improved = TryRemainderImprovement(nestItem, workArea, best);
if (IsBetterFill(improved, best, workArea))
try
{
Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
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");
}
}
if (best == null || best.Count == 0)
return false;
Plate.Parts.AddRange(best);
return true;
return best ?? new List<Part>();
}
public bool Pack(List<NestItem> items)
@@ -249,6 +389,54 @@ namespace OpenNest
return best ?? new List<Part>();
}
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>();
}
/// <summary>
/// Selects pair candidates to try for the given work area. Always includes
/// the top 50 by area. For narrow work areas, also includes all pairs whose
@@ -533,5 +721,31 @@ namespace OpenNest
return best;
}
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
});
}
}
}
+22
View File
@@ -0,0 +1,22 @@
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; }
}
}
+2 -11
View File
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
@@ -171,19 +170,14 @@ namespace OpenNest.Actions
private void Fill()
{
var sw = Stopwatch.StartNew();
var plate = plateView.Plate;
var engine = new NestEngine(plate);
var groupParts = parts.Select(p => p.BasePart).ToList();
var bounds = plate.WorkArea();
if (plate.Parts.Count == 0)
{
engine.Fill(groupParts);
sw.Stop();
plateView.Status = $"Fill: {plate.Parts.Count} parts in {sw.ElapsedMilliseconds} ms";
plateView.FillWithProgress(groupParts, bounds);
return;
}
@@ -202,10 +196,7 @@ namespace OpenNest.Actions
if (bestArea == Box.Empty)
return;
var before = plate.Parts.Count;
engine.Fill(groupParts, bestArea);
sw.Stop();
plateView.Status = $"Fill: {plate.Parts.Count - before} parts in {sw.ElapsedMilliseconds} ms";
plateView.FillWithProgress(groupParts, bestArea);
}
}
}
+44 -6
View File
@@ -1,4 +1,8 @@
using System.ComponentModel;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Controls;
@@ -8,26 +12,60 @@ namespace OpenNest.Actions
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, System.Windows.Forms.PreviewKeyDownEventArgs e)
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 void FillArea()
private async void FillArea()
{
var engine = new NestEngine(plateView.Plate);
engine.Fill(new NestItem { Drawing = drawing }, SelectedArea);
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();
}
plateView.Invalidate();
Update();
}
+40
View File
@@ -11,6 +11,23 @@ namespace OpenNest
private Color rapidColor;
private Color originColor;
private Color edgeSpacingColor;
private Color previewPartColor;
public static readonly Color[] PartColors = new Color[]
{
Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple
Color.FromArgb(75, 180, 175), // Teal
Color.FromArgb(210, 190, 75), // Goldenrod
Color.FromArgb(190, 85, 175), // Orchid
Color.FromArgb(185, 115, 85), // Sienna
Color.FromArgb(120, 100, 190), // Slate Blue
Color.FromArgb(200, 100, 140), // Rose
Color.FromArgb(80, 175, 155), // Sea Green
Color.FromArgb(195, 160, 85), // Dark Khaki
Color.FromArgb(175, 95, 160), // Plum
Color.FromArgb(215, 130, 130), // Light Coral
};
public static readonly ColorScheme Default = new ColorScheme
{
@@ -21,6 +38,7 @@ namespace OpenNest
RapidColor = Color.DodgerBlue,
OriginColor = Color.Gray,
EdgeSpacingColor = Color.FromArgb(180, 180, 180),
PreviewPartColor = Color.FromArgb(255, 140, 0),
};
#region Pens/Brushes
@@ -37,6 +55,10 @@ namespace OpenNest
public Pen EdgeSpacingPen { get; private set; }
public Pen PreviewPartPen { get; private set; }
public Brush PreviewPartBrush { get; private set; }
#endregion Pens/Brushes
#region Colors
@@ -135,6 +157,24 @@ namespace OpenNest
}
}
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));
}
}
#endregion Colors
}
}
+99
View File
@@ -2,14 +2,18 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.CNC;
using OpenNest.Collections;
using OpenNest.Converters;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Math;
using Action = OpenNest.Actions.Action;
@@ -27,6 +31,7 @@ namespace OpenNest.Controls
private Action currentAction;
private Action previousAction;
private List<LayoutPart> parts;
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
private Point middleMouseDownPoint;
public List<LayoutPart> SelectedParts;
@@ -120,6 +125,7 @@ namespace OpenNest.Controls
plate.PartAdded -= plate_PartAdded;
plate.PartRemoved -= plate_PartRemoved;
parts.Clear();
temporaryParts.Clear();
SelectedParts.Clear();
}
@@ -376,6 +382,7 @@ namespace OpenNest.Controls
public override void Refresh()
{
parts.ForEach(p => p.Update(this));
temporaryParts.ForEach(p => p.Update(this));
Invalidate();
}
@@ -470,6 +477,24 @@ namespace OpenNest.Controls
part.Draw(g, (i + 1).ToString());
}
// 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);
}
if (DrawOffset && Plate.PartSpacing > 0)
DrawOffsetGeometry(g);
@@ -783,6 +808,80 @@ namespace OpenNest.Controls
Plate.Parts.Add(part);
}
public void SetTemporaryParts(List<Part> parts)
{
temporaryParts.Clear();
if (parts != null)
{
foreach (var part in parts)
temporaryParts.Add(LayoutPart.Create(part, this));
}
Invalidate();
}
public void ClearTemporaryParts()
{
temporaryParts.Clear();
Invalidate();
}
public int AcceptTemporaryParts()
{
var count = temporaryParts.Count;
foreach (var layoutPart in temporaryParts)
Plate.Parts.Add(layoutPart.BasePart);
temporaryParts.Clear();
return count;
}
public async void FillWithProgress(List<Part> groupParts, Box workArea)
{
var sw = Stopwatch.StartNew();
var cts = new CancellationTokenSource();
var progressForm = new NestProgressForm(cts, showPlateRow: false);
var progress = new Progress<NestProgress>(p =>
{
progressForm.UpdateProgress(p);
SetTemporaryParts(p.BestParts);
});
progressForm.Show(FindForm());
try
{
var engine = new NestEngine(Plate);
var parts = await Task.Run(() =>
engine.Fill(groupParts, workArea, progress, cts.Token));
if (parts.Count > 0)
{
AcceptTemporaryParts();
sw.Stop();
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
}
else
{
ClearTemporaryParts();
}
progressForm.ShowCompleted();
}
catch (Exception)
{
ClearTemporaryParts();
}
finally
{
progressForm.Close();
cts.Dispose();
}
}
public void RemoveSelectedParts()
{
foreach (var part in SelectedParts)
+5 -162
View File
@@ -82,17 +82,13 @@ namespace OpenNest.Forms
checkedListBox3.Items.Add(lineType, false);
}
private static int colorIndex;
private static Color GetNextColor()
{
//if (colorIndex >= Colors.Length)
// colorIndex = 0;
//var color = Colors[colorIndex];
//colorIndex++;
return new HSLColor(new Random().NextDouble() * 240, 240, 160);
var color = ColorScheme.PartColors[colorIndex % ColorScheme.PartColors.Length];
colorIndex++;
return color;
}
public List<Drawing> GetDrawings()
@@ -331,157 +327,4 @@ namespace OpenNest.Forms
public override int GetHashCode() => Argb;
}
public class RandomColorGenerator
{
private readonly Random random;
public RandomColorGenerator()
{
random = new Random();
}
public Color GetNext()
{
var r = random.Next(255);
Thread.Sleep(20);
var g = random.Next(255);
Thread.Sleep(20);
var b = random.Next(255);
return Color.FromArgb(r, g, b);
}
}
public class HSLColor
{
// Private data members below are on scale 0-1
// They are scaled for use externally based on scale
private double hue = 1.0;
private double saturation = 1.0;
private double luminosity = 1.0;
private const double scale = 240.0;
public double Hue
{
get { return hue * scale; }
set { hue = CheckRange(value / scale); }
}
public double Saturation
{
get { return saturation * scale; }
set { saturation = CheckRange(value / scale); }
}
public double Luminosity
{
get { return luminosity * scale; }
set { luminosity = CheckRange(value / scale); }
}
private double CheckRange(double value)
{
if (value < 0.0)
value = 0.0;
else if (value > 1.0)
value = 1.0;
return value;
}
public override string ToString()
{
return String.Format("H: {0:#0.##} S: {1:#0.##} L: {2:#0.##}", Hue, Saturation, Luminosity);
}
public string ToRGBString()
{
Color color = (Color)this;
return String.Format("R: {0:#0.##} G: {1:#0.##} B: {2:#0.##}", color.R, color.G, color.B);
}
#region Casts to/from System.Drawing.Color
public static implicit operator Color(HSLColor hslColor)
{
double r = 0, g = 0, b = 0;
if (hslColor.luminosity != 0)
{
if (hslColor.saturation == 0)
r = g = b = hslColor.luminosity;
else
{
double temp2 = GetTemp2(hslColor);
double temp1 = 2.0 * hslColor.luminosity - temp2;
r = GetColorComponent(temp1, temp2, hslColor.hue + 1.0 / 3.0);
g = GetColorComponent(temp1, temp2, hslColor.hue);
b = GetColorComponent(temp1, temp2, hslColor.hue - 1.0 / 3.0);
}
}
return Color.FromArgb((int)(255 * r), (int)(255 * g), (int)(255 * b));
}
private static double GetColorComponent(double temp1, double temp2, double temp3)
{
temp3 = MoveIntoRange(temp3);
if (temp3 < 1.0 / 6.0)
return temp1 + (temp2 - temp1) * 6.0 * temp3;
else if (temp3 < 0.5)
return temp2;
else if (temp3 < 2.0 / 3.0)
return temp1 + ((temp2 - temp1) * ((2.0 / 3.0) - temp3) * 6.0);
else
return temp1;
}
private static double MoveIntoRange(double temp3)
{
if (temp3 < 0.0)
temp3 += 1.0;
else if (temp3 > 1.0)
temp3 -= 1.0;
return temp3;
}
private static double GetTemp2(HSLColor hslColor)
{
double temp2;
if (hslColor.luminosity < 0.5) //<=??
temp2 = hslColor.luminosity * (1.0 + hslColor.saturation);
else
temp2 = hslColor.luminosity + hslColor.saturation - (hslColor.luminosity * hslColor.saturation);
return temp2;
}
public static implicit operator HSLColor(Color color)
{
HSLColor hslColor = new HSLColor();
hslColor.hue = color.GetHue() / 360.0; // we store hue as 0-1 as opposed to 0-360
hslColor.luminosity = color.GetBrightness();
hslColor.saturation = color.GetSaturation();
return hslColor;
}
#endregion
public void SetRGB(int red, int green, int blue)
{
HSLColor hslColor = (HSLColor)Color.FromArgb(red, green, blue);
this.hue = hslColor.hue;
this.saturation = hslColor.saturation;
this.luminosity = hslColor.luminosity;
}
public HSLColor() { }
public HSLColor(Color color)
{
SetRGB(color.R, color.G, color.B);
}
public HSLColor(int red, int green, int blue)
{
SetRGB(red, green, blue);
}
public HSLColor(double hue, double saturation, double luminosity)
{
this.Hue = hue;
this.Saturation = saturation;
this.Luminosity = luminosity;
}
}
}
+176 -28
View File
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Collections;
@@ -18,6 +21,8 @@ namespace OpenNest.Forms
{
private EditNestForm activeForm;
private bool clickUpdateLocation;
private bool nestingInProgress;
private CancellationTokenSource nestingCts;
private const float ZoomInFactor = 1.5f;
private const float ZoomOutFactor = 1.0f / ZoomInFactor;
@@ -165,6 +170,25 @@ namespace OpenNest.Forms
}
}
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;
}
private void UpdateLocationStatus()
{
if (activeForm == null)
@@ -290,6 +314,12 @@ namespace OpenNest.Forms
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
}
// If nesting is in progress and the active form changed, cancel nesting
if (nestingInProgress && nestingCts != null)
{
nestingCts.Cancel();
}
activeForm = ActiveMdiChild as EditNestForm;
EnableCheck();
@@ -689,7 +719,7 @@ namespace OpenNest.Forms
activeForm.LoadNextPlate();
}
private void RunAutoNest_Click(object sender, EventArgs e)
private async void RunAutoNest_Click(object sender, EventArgs e)
{
var form = new AutoNestForm(activeForm.Nest);
form.AllowPlateCreation = true;
@@ -699,41 +729,96 @@ namespace OpenNest.Forms
var items = form.GetNestItems();
while (items.Any(it => it.Quantity > 0))
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 =>
{
var plate = activeForm.PlateView.Plate.Parts.Count > 0
? activeForm.Nest.CreatePlate()
: activeForm.PlateView.Plate;
progressForm.UpdateProgress(p);
activeForm.PlateView.SetTemporaryParts(p.BestParts);
});
var engine = new NestEngine(plate);
var filled = false;
progressForm.Show(this);
SetNestingLockout(true);
foreach (var item in items)
try
{
while (items.Any(it => it.Quantity > 0))
{
if (item.Quantity <= 0)
continue;
if (token.IsCancellationRequested)
break;
if (engine.Fill(item))
filled = true;
}
var plate = activeForm.PlateView.Plate.Parts.Count > 0
? activeForm.Nest.CreatePlate()
: activeForm.PlateView.Plate;
if (!filled)
break;
// If a new plate was created, switch to it
if (plate != activeForm.PlateView.Plate)
activeForm.LoadLastPlate();
// Decrement requested quantities by counting parts actually
// placed on this plate, grouped by drawing.
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing))
{
var placed = group.Count();
var engine = new NestEngine(plate) { PlateNumber = plateNumber };
var filled = false;
foreach (var item in items)
{
if (item.Drawing == group.Key)
item.Quantity -= placed;
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;
}
}
@@ -782,7 +867,7 @@ namespace OpenNest.Forms
activeForm.SetCurrentPlateAsNestDefault();
}
private void FillPlate_Click(object sender, EventArgs e)
private async void FillPlate_Click(object sender, EventArgs e)
{
if (activeForm == null)
return;
@@ -798,13 +883,49 @@ namespace OpenNest.Forms
if (drawing == null)
return;
var engine = new NestEngine(activeForm.PlateView.Plate);
engine.Fill(new NestItem
nestingCts = new CancellationTokenSource();
var token = nestingCts.Token;
var progressForm = new NestProgressForm(nestingCts, showPlateRow: false);
var progress = new Progress<NestProgress>(p =>
{
Drawing = drawing
progressForm.UpdateProgress(p);
activeForm.PlateView.SetTemporaryParts(p.BestParts);
});
activeForm.PlateView.Invalidate();
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;
}
}
private void FillArea_Click(object sender, EventArgs e)
@@ -823,7 +944,34 @@ namespace OpenNest.Forms
if (drawing == null)
return;
activeForm.PlateView.SetAction(typeof(ActionFillArea), drawing);
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);
}
private void AddPlate_Click(object sender, EventArgs e)
+184
View File
@@ -0,0 +1,184 @@
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 = "\u2014",
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();
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
# Nesting Progress Window Design
## Problem
The auto-nest and fill operations run synchronously on the UI thread, freezing the application until complete. The user has no visibility into what the engine is doing, no way to stop early, and no preview of intermediate results.
## Solution
Run nesting on a background thread with `IProgress<NestProgress>` callbacks. Show a modeless progress dialog with current-best stats and a Stop button. Render the current best layout as temporary parts on the PlateView in a distinct preview color.
## Progress Data Model
**New file: `OpenNest.Engine/NestProgress.cs`**
A class carrying progress updates from the engine to the UI:
- `Phase` (NestPhase enum): Current strategy — `Linear`, `RectBestFit`, `Pairs`, `Remainder`
- `PlateNumber` (int): Current plate number (for auto-nest multi-plate loop)
- `BestPartCount` (int): Part count of current best result
- `BestDensity` (double): Density percentage of current best
- `UsableRemnantArea` (double): Usable remnant area of current best (matches `FillScore.UsableRemnantArea`)
- `BestParts` (List\<Part\>): Cloned snapshot of the best parts for preview
`Phase` uses a `NestPhase` enum (defined in the same file) to prevent typos and allow the progress form to map to display-friendly text (e.g., `NestPhase.Pairs` → "Trying pairs...").
`BestParts` must be a cloned list (using `Part.Clone()`) so the UI thread can safely read it while the engine continues on the background thread. The clones share `BaseDrawing` references (not deep copies of drawings) since drawings are read-only templates during nesting. Progress is reported only after each phase completes (3-4 reports per fill call), so the cloning cost is negligible.
## Engine Changes
**Modified file: `OpenNest.Engine/NestEngine.cs`**
### Return type change
The new overloads return `List<Part>` instead of `bool`. They do **not** call `Plate.Parts.AddRange()` — the caller is responsible for committing parts to the plate. This is critical because:
1. The engine runs on a background thread and must not touch `Plate.Parts` (an `ObservableList` that fires UI events).
2. It cleanly separates the "compute" phase from the "commit" phase, allowing the UI to preview results as temporary parts before committing.
Existing `bool Fill(...)` overloads remain unchanged — they delegate to the new overloads and call `Plate.Parts.AddRange()` themselves, preserving current behavior for all existing callers (MCP tools, etc.).
### Fill overloads
New signatures:
- `List<Part> Fill(NestItem item, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
- `List<Part> Fill(List<Part> groupParts, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
**Note on `Fill(List<Part>, ...)` overload:** When `groupParts.Count > 1`, only the Linear phase runs (no RectBestFit, Pairs, or Remainder). The additional phases only apply when `groupParts.Count == 1`, matching the existing engine behavior.
Inside `FindBestFill`, after each strategy completes:
1. **Linear phase**: Try all rotation angles (already uses `Parallel.ForEach`). If new best found, report progress with `Phase=Linear`. Check cancellation token.
2. **RectBestFit phase**: If new best found, report progress with `Phase=RectBestFit`. Check cancellation token.
3. **Pairs phase**: Try all pair candidates (already uses `Parallel.For`). If new best found, report progress with `Phase=Pairs`. Check cancellation token.
4. **Remainder improvement**: If new best found, report progress with `Phase=Remainder`.
### Cancellation Behavior
On cancellation, the engine returns its current best result (not null/empty). `OperationCanceledException` is caught internally so the caller always gets a usable result. This enables "stop early, keep best result."
The `CancellationToken` is also passed into `ParallelOptions` for the existing `Parallel.ForEach` (linear phase) and `Parallel.For` (pairs phase) loops, so cancellation is responsive even mid-phase rather than only at phase boundaries.
## PlateView Temporary Parts
**Modified file: `OpenNest/Controls/PlateView.cs`**
Add a separate temporary parts list alongside the existing `parts` list:
```csharp
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
```
### Drawing
In `DrawParts`, after drawing real parts, iterate `temporaryParts` and draw them using a distinct preview color. The preview color is added to `ColorScheme` (e.g., `PreviewPart`) so it follows the existing theming pattern. Same drawing logic, different pen/brush.
### Public API
- `SetTemporaryParts(List<Part> parts)` — Clears existing temp parts, builds `LayoutPart` wrappers from the provided parts, triggers redraw.
- `ClearTemporaryParts()` — Clears temp parts and redraws.
- `AcceptTemporaryParts()` — Adds the temp parts to the real `Plate.Parts` collection (which triggers quantity tracking via `ObservableList` events), then clears the temp list.
`AcceptTemporaryParts()` is the sole "commit" path. The engine never writes to `Plate.Parts` directly when using the progress overloads.
### Thread Safety
`SetTemporaryParts` is called from `IProgress<T>` callbacks. When using `Progress<T>` constructed on the UI thread, callbacks are automatically marshalled via `SynchronizationContext`. No extra marshalling needed.
## NestProgressForm (Modeless Dialog)
**New files: `OpenNest/Forms/NestProgressForm.cs`, `NestProgressForm.Designer.cs`, `NestProgressForm.resx`**
A small modeless dialog with a `TableLayoutPanel` layout:
```
┌──────────────────────────────┐
│ Phase: Trying pairs... │
│ Plate: 2 │
│ Parts: 156 │
│ Density: 68.3% │
│ Remnant: 0.0 sq in │
│ │
│ [ Stop ] │
└──────────────────────────────┘
```
Two-column `TableLayoutPanel`: left column is fixed-width labels, right column is auto-sized values. Stop button below the table.
The Plate row shows just the current plate number (no total — the total is not known in advance since it depends on how many parts fit per plate). The Plate row is hidden when running a single-plate fill.
### Behavior
- Opened via `form.Show(owner)` — modeless, stays on top of MainForm
- Receives `NestProgress` updates and refreshes labels
- Stop button triggers `CancellationTokenSource.Cancel()`, changes text to "Stopping..." (disabled)
- On engine completion (natural or cancelled), auto-closes or shows "Done" with Close button
- Closing via X button acts the same as Stop — cancels and accepts current best
## MainForm Integration
**Modified file: `OpenNest/Forms/MainForm.cs`**
### RunAutoNest_Click (auto-nest)
1. Show `AutoNestForm` dialog as before to get `NestItems`
2. Create `CancellationTokenSource`, `Progress<NestProgress>`, `NestProgressForm`
3. Open progress form modeless
4. `Task.Run` with the multi-plate loop. The background work computes results only — all UI/plate mutation happens on the UI thread via `Progress<T>` callbacks and the `await` continuation:
- The loop iterates items, calling the new `Fill(item, workArea, progress, token)` overloads which return `List<Part>` without modifying the plate.
- Progress callbacks update the preview via `SetTemporaryParts()` on the UI thread.
- When a plate's fill completes, the continuation (back on the UI thread) counts the returned parts per drawing (from the last `NestProgress.BestParts`) to decrement `NestItem.Quantity`, then calls `AcceptTemporaryParts()` to commit to the plate. `Nest.CreatePlate()` for the next plate also happens on the UI thread.
- On cancellation, breaks out of the loop and commits whatever was last previewed.
5. On completion, call `Nest.UpdateDrawingQuantities()`, close progress form
6. Disable nesting-related menu items while running, re-enable on completion
7. Dispose `CancellationTokenSource` when done
### ActionFillArea / single-plate Fill
Same pattern but simpler — no plate loop, single fill call with progress/cancellation. The progress form is created and owned by the code that launches the fill (in MainForm, not inside ActionFillArea). The Plate row is hidden. Escape key during the action cancels the token (same as clicking Stop).
### UI Lockout
While the engine runs, the user can pan/zoom the PlateView (read-only interaction) but editing actions (add/remove parts, change plates, plate navigation) are disabled. Plate navigation is locked during auto-nest to prevent the PlateView from switching away from the plate being filled. Re-enabled when nesting completes or is stopped.
If the user closes the `EditNestForm` (MDI child) while nesting is running, the cancellation token is triggered and the progress form is closed. No partial results are committed.
### Error Handling
The `Task.Run` body is wrapped in try/catch. If the engine throws an unexpected exception (not `OperationCanceledException`), the continuation shows a `MessageBox` with the error, clears temporary parts, and re-enables the UI. No partial results are committed on unexpected errors.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest.Engine/NestProgress.cs` | New — progress data model + `NestPhase` enum |
| `OpenNest.Engine/NestEngine.cs` | New `List<Part>`-returning overloads with `IProgress`/`CancellationToken` |
| `OpenNest/Controls/PlateView.cs` | Temporary parts list + drawing |
| `OpenNest/Forms/NestProgressForm.cs` (+Designer, +resx) | New — modeless progress dialog |
| `OpenNest/Forms/MainForm.cs` | Rewire auto-nest and fill to async with progress |
## Not Changed
OpenNest.Core, OpenNest.IO, OpenNest.Mcp, EditNestForm, existing engine callers (MCP tools, etc.). The existing `bool Fill(...)` overloads continue to work as before by delegating to the new overloads and calling `Plate.Parts.AddRange()` themselves.