From 37130e8a2830774efc457aad81511a60e4f9c9bc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 5 Apr 2026 17:56:54 -0400 Subject: [PATCH] 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) --- OpenNest.IO/NestWriter.cs | 8 +- .../Api/NestResponsePersistenceTests.cs | 3 + OpenNest/Forms/EditNestForm.Designer.cs | 18 +++- OpenNest/Forms/EditNestForm.cs | 95 ++++++++++++++++++- OpenNest/Forms/MainForm.cs | 10 ++ 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index ed8606e..6446474 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -175,9 +175,15 @@ namespace OpenNest.IO private List BuildPlateDtos() { var list = new List(); + 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(); 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, diff --git a/OpenNest.Tests/Api/NestResponsePersistenceTests.cs b/OpenNest.Tests/Api/NestResponsePersistenceTests.cs index 5e3a026..67aa709 100644 --- a/OpenNest.Tests/Api/NestResponsePersistenceTests.cs +++ b/OpenNest.Tests/Api/NestResponsePersistenceTests.cs @@ -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 diff --git a/OpenNest/Forms/EditNestForm.Designer.cs b/OpenNest/Forms/EditNestForm.Designer.cs index fb39b43..3e1360d 100644 --- a/OpenNest/Forms/EditNestForm.Designer.cs +++ b/OpenNest/Forms/EditNestForm.Designer.cs @@ -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; diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 26d4484..920c449 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -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 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; + } + + /// + /// Ensures a single empty sentinel plate exists at the end of the nest. + /// + 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 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) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 525bd3c..9cbc52f 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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 {