feat: add sentinel plate and plate list enhancements

Always keep a trailing empty plate so users can immediately place parts
without manually adding a plate. Auto-appends a new sentinel when parts
land on the last plate; trims excess trailing empties on removal.

Plate list now shows Parts count and Utilization % columns. Empty plates
are filtered from save and export. Sentinel updates are deferred via
BeginInvoke to avoid collection-modified exceptions and debounced to
prevent per-part overhead on bulk operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 17:56:54 -04:00
parent 6f19fe1822
commit 37130e8a28
5 changed files with 127 additions and 7 deletions

View File

@@ -175,9 +175,15 @@ namespace OpenNest.IO
private List<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
var id = 0;
for (var i = 0; i < nest.Plates.Count; i++)
{
var plate = nest.Plates[i];
if (plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0)
continue;
id++;
var parts = new List<PartDto>();
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
{
@@ -208,7 +214,7 @@ namespace OpenNest.IO
list.Add(new PlateDto
{
Id = i + 1,
Id = id,
Size = new SizeDto { Width = plate.Size.Width, Length = plate.Size.Length },
Quadrant = plate.Quadrant,
Quantity = plate.Quantity,

View File

@@ -13,6 +13,9 @@ public class NestResponsePersistenceTests
{
var nest = new Nest("test-nest");
var plate = new Plate(new Size(60, 120));
var drawing = new Drawing("test-part");
nest.Drawings.Add(drawing);
plate.Parts.Add(new Part(drawing));
nest.Plates.Add(plate);
var request = new NestRequest

View File

@@ -36,6 +36,8 @@
plateNumColumn = new System.Windows.Forms.ColumnHeader();
sizeColumn = new System.Windows.Forms.ColumnHeader();
qtyColumn = new System.Windows.Forms.ColumnHeader();
partsColumn = new System.Windows.Forms.ColumnHeader();
utilColumn = new System.Windows.Forms.ColumnHeader();
toolStrip1 = new System.Windows.Forms.ToolStrip();
tabPage2 = new System.Windows.Forms.TabPage();
drawingListBox1 = new OpenNest.Controls.DrawingListBox();
@@ -100,7 +102,7 @@
// platesListView
//
platesListView.BorderStyle = System.Windows.Forms.BorderStyle.None;
platesListView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { plateNumColumn, sizeColumn, qtyColumn });
platesListView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { plateNumColumn, sizeColumn, qtyColumn, partsColumn, utilColumn });
platesListView.Dock = System.Windows.Forms.DockStyle.Fill;
platesListView.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, 0);
platesListView.FullRowSelect = true;
@@ -126,8 +128,18 @@
sizeColumn.Width = 80;
//
// qtyColumn
//
//
qtyColumn.Text = "Qty";
//
// partsColumn
//
partsColumn.Text = "Parts";
partsColumn.Width = 45;
//
// utilColumn
//
utilColumn.Text = "Util";
utilColumn.Width = 45;
//
// toolStrip1
//
@@ -263,6 +275,8 @@
private System.Windows.Forms.TabPage tabPage2;
private Controls.DrawingListBox drawingListBox1;
private System.Windows.Forms.ColumnHeader plateNumColumn;
private System.Windows.Forms.ColumnHeader partsColumn;
private System.Windows.Forms.ColumnHeader utilColumn;
private System.Windows.Forms.ToolStrip toolStrip2;
private System.Windows.Forms.ToolStripButton toolStripButton2;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;

View File

@@ -27,6 +27,9 @@ namespace OpenNest.Forms
public readonly PlateView PlateView;
private readonly Timer updateDrawingListTimer;
private bool suppressPlateNavigation;
private bool updatingPlateList;
private bool sentinelUpdatePending;
private Panel plateHeaderPanel;
private Label plateInfoLabel;
@@ -67,7 +70,7 @@ namespace OpenNest.Forms
platesListView.SelectedIndexChanged += (sender, e) =>
{
if (platesListView.SelectedIndices.Count == 0)
if (updatingPlateList || platesListView.SelectedIndices.Count == 0)
return;
CurrentPlateIndex = platesListView.SelectedIndices[0];
@@ -217,6 +220,8 @@ namespace OpenNest.Forms
if (Nest.Plates.Count == 0)
Nest.CreatePlate();
EnsureSentinelPlate();
UpdatePlateList();
UpdateDrawingList();
UpdateRemovePlateButton();
@@ -299,6 +304,10 @@ namespace OpenNest.Forms
public void UpdatePlateList()
{
updatingPlateList = true;
var focused = ContainsFocus ? GetFocusedControl() : null;
platesListView.BeginUpdate();
platesListView.Items.Clear();
var items = new ListViewItem[Nest.Plates.Count];
@@ -311,6 +320,23 @@ namespace OpenNest.Forms
}
platesListView.Items.AddRange(items);
if (CurrentPlateIndex < platesListView.Items.Count)
platesListView.Items[CurrentPlateIndex].Selected = true;
platesListView.EndUpdate();
updatingPlateList = false;
if (focused != null && focused != platesListView)
focused.Focus();
}
private Control GetFocusedControl()
{
var ctrl = this;
while (ctrl is ContainerControl container && container.ActiveControl != null)
return container.ActiveControl;
return ctrl;
}
public void UpdateDrawingList()
@@ -943,6 +969,13 @@ namespace OpenNest.Forms
private void Plates_PlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
{
if (suppressPlateNavigation)
{
UpdatePlateList();
UpdateRemovePlateButton();
return;
}
if (Nest.Plates.Count <= CurrentPlateIndex)
LoadLastPlate();
else
@@ -958,13 +991,43 @@ namespace OpenNest.Forms
tabControl1.SelectedIndex = 0;
UpdatePlateList();
UpdateRemovePlateButton();
LoadLastPlate();
PlateView.ZoomToFit();
if (!suppressPlateNavigation)
{
LoadLastPlate();
PlateView.ZoomToFit();
}
}
private void UpdateRemovePlateButton()
{
btnRemovePlate.Enabled = Nest.Plates.Count > 1;
var plate = Nest.Plates.Count > 0 ? Nest.Plates[CurrentPlateIndex] : null;
btnRemovePlate.Enabled = Nest.Plates.Count > 1 && plate != null && plate.Parts.Count > 0;
}
/// <summary>
/// Ensures a single empty sentinel plate exists at the end of the nest.
/// </summary>
public void EnsureSentinelPlate()
{
suppressPlateNavigation = true;
try
{
if (Nest.Plates.Count == 0 || Nest.Plates[^1].Parts.Count > 0)
Nest.CreatePlate();
// Trim excess trailing empty plates back to one
while (Nest.Plates.Count > 1
&& Nest.Plates[^1].Parts.Count == 0
&& Nest.Plates[^2].Parts.Count == 0)
{
Nest.Plates.RemoveAt(Nest.Plates.Count - 1);
}
}
finally
{
suppressPlateNavigation = false;
}
}
#endregion
@@ -975,6 +1038,13 @@ namespace OpenNest.Forms
item.Text = id.ToString();
item.SubItems.Add(plate.Size.ToString());
item.SubItems.Add(plate.Quantity.ToString());
var partCount = plate.Parts.Count(p => !p.BaseDrawing.IsCutOff);
item.SubItems.Add(partCount.ToString());
var util = plate.Utilization();
item.SubItems.Add(partCount > 0 ? $"{util:P0}" : "");
return item;
}
@@ -982,12 +1052,29 @@ namespace OpenNest.Forms
{
updateDrawingListTimer.Stop();
updateDrawingListTimer.Start();
DeferSentinelUpdate();
}
private void PlateView_PartAdded(object sender, ItemAddedEventArgs<Part> e)
{
updateDrawingListTimer.Stop();
updateDrawingListTimer.Start();
DeferSentinelUpdate();
}
private void DeferSentinelUpdate()
{
if (sentinelUpdatePending)
return;
sentinelUpdatePending = true;
BeginInvoke(new MethodInvoker(() =>
{
sentinelUpdatePending = false;
EnsureSentinelPlate();
UpdatePlateList();
UpdateRemovePlateButton();
}));
}
private void drawingListUpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)

View File

@@ -827,6 +827,7 @@ namespace OpenNest.Forms
{
if (activeForm == null) return;
activeForm.Nest.Plates.RemoveEmptyPlates();
activeForm.EnsureSentinelPlate();
}
private void LoadFirstPlate_Click(object sender, EventArgs e)
@@ -963,6 +964,7 @@ namespace OpenNest.Forms
SetNestingLockout(false);
nestingCts.Dispose();
nestingCts = null;
activeForm.EnsureSentinelPlate();
}
}
@@ -1033,6 +1035,14 @@ namespace OpenNest.Forms
plate.Size = new Geometry.Size(result.ChosenSize.Width, result.ChosenSize.Length);
nestParts = result.Parts;
// Deduct placed quantities — the optimizer clones items internally
// so the originals are untouched after dry runs.
foreach (var item in items)
{
var placed = nestParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
}
else
{