fix(ui): exclude the new-plate sentinel from display counts

The editor header and the Plan Cutting dialog counted the trailing empty
new-plate workspace PlateManager.EnsureSentinel maintains, so a one-plate
nest read 'Plate 1 of 2'. PlateDisplayNumbering now derives display-only
numbers that exclude only a trailing empty sentinel; interior empty plates
keep their slot and number, and navigation, storage indexes, exported
names and batch selection are untouched. The sentinel itself is labeled
'New plate (empty)' instead of being numbered beyond the shown total.
This commit is contained in:
aj committed 2026-10-06 20:58:32 -04:00
1 parent bb43118451
commit b8f319d3e8
6 files changed
+336 -17

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
using System.Collections.Generic;
namespace OpenNest
{
/// <summary>
/// Display-only plate numbering for labels. The editor keeps one trailing empty plate as the
/// "new plate" workspace (PlateManager.EnsureSentinel), and that sentinel is excluded from the
/// shown total so a one-plate nest reads "Plate 1 of 1". Navigation, storage indexes, exported
/// names and batch selection keep using the real collection positions; an interior empty plate
/// keeps its slot and its number.
/// </summary>
public static class PlateDisplayNumbering
{
/// <summary>
/// The number shown for the plate at <paramref name="storageIndex"/>: real collection
/// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
/// sentinel and for indexes outside the collection.
/// </summary>
public static int? DisplayedPlateNumber(IList<Plate> plates, int storageIndex)
{
if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
return null;
if (IsTrailingSentinel(plates, storageIndex))
return null;
return storageIndex + 1;
}
/// <summary>
/// The number shown in an "of M" total: the plates minus only a trailing empty sentinel.
/// An all-empty collection shows nothing (the caller says "No plates").
/// </summary>
public static int DisplayedPlateCount(IList<Plate> plates)
{
if (plates == null || plates.Count == 0)
return 0;
var last = plates[plates.Count - 1];
return plates.Count - (last != null && last.Parts.Count == 0 ? 1 : 0);
}
/// <summary>True when <paramref name="storageIndex"/> is the trailing empty new-plate sentinel.</summary>
public static bool IsTrailingSentinel(IList<Plate> plates, int storageIndex)
{
if (plates == null || plates.Count == 0)
return false;
if (storageIndex != plates.Count - 1)
return false;
var plate = plates[storageIndex];
return plate != null && plate.Parts.Count == 0;
}
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText)
{
var displayed = DisplayedPlateNumber(plates, storageIndex);
if (displayed == null)
return IsTrailingSentinel(plates, storageIndex)
? "New plate (empty)"
: "No plates";
return string.IsNullOrEmpty(plateSizeText)
? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates))
: string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText);
}
}
}
@@ -0,0 +1,138 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class PlateDisplayNumberingTests
{
private static Nest CreateNest() => new("test");
private static Part MakePart()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Part(new Drawing("test", pgm));
}
[Fact]
public void PopulatedThenSentinel_TotalIsOneAndRealPlateDisplaysAsOne()
{
var nest = CreateNest();
var real = nest.CreatePlate();
real.Parts.Add(MakePart());
nest.CreatePlate(); // The trailing empty sentinel.
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal("Plate 1 of 1", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void Sentinel_IsLabeledAsNewPlateNotNumbered()
{
var nest = CreateNest();
var real = nest.CreatePlate();
real.Parts.Add(MakePart());
nest.CreatePlate();
var sentinel = nest.Plates.Count - 1;
Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, sentinel));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, sentinel));
Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, sentinel, null));
}
[Fact]
public void TwoRealPlatesThenSentinel_TotalIsTwo()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.Plates.Add(WithPart());
nest.CreatePlate(); // sentinel
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2));
}
[Fact]
public void NoPlates_TotalIsZeroAndHeaderSaysNoPlates()
{
var nest = CreateNest();
Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal("No plates", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void SingleEmptyPlate_IsTheNewPlateView_NotNumbered()
{
var nest = CreateNest();
nest.CreatePlate(); // only the sentinel
Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0));
Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void InteriorEmptyPlate_KeepsItsSlotAndNumber()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.CreatePlate(); // An empty interior plate: EnsureSentinel only trims at the tail.
nest.Plates.Add(WithPart());
nest.CreatePlate(); // sentinel
Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1));
Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 1));
}
[Fact]
public void LastPlateWithParts_IsNotASentinel()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0));
}
[Fact]
public void IndexOutsideCollection_HasNoNumberAndIsNotASentinel()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 5));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, -1));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 5));
}
[Fact]
public void Helper_DoesNotMutateTheNest()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.CreatePlate();
var before = nest.Plates.Count;
PlateDisplayNumbering.DisplayedPlateCount(nest.Plates);
PlateDisplayNumbering.FormatHeader(nest.Plates, 1, "100 x 100");
Assert.Equal(before, nest.Plates.Count);
}
private static Plate WithPart()
{
var plate = new Plate();
plate.Parts.Add(MakePart());
return plate;
}
}
@@ -202,6 +202,31 @@ public class CuttingPlanFormTests
Assert.All(first.Parts, part => Assert.False(part.HasManualLeadIns));
});
[Fact]
public void OnePlateDialog_ExcludesTheSentinelFromTheShownTotal() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1));
nest.CreatePlate(); // The editor's trailing empty new-plate sentinel.
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
Assert.StartsWith("Plate 1 of 1, numbered in cutting order:", Control<Label>(form, "previewLabel").Text);
});
[Fact]
public void SentinelActivePlate_IsLabeledNotNumberedBeyondTheTotal() => RunSta(() =>
{
var (nest, _) = CreateView(Square("a", 1, 1));
var sentinel = nest.CreatePlate(); // trailing empty new-plate sentinel
using var view = new PlateView { Plate = sentinel };
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
var text = Control<Label>(form, "previewLabel").Text;
Assert.StartsWith("New plate (empty)", text);
Assert.DoesNotContain("Plate 2", text);
});
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
{
var nest = new Nest();
@@ -0,0 +1,96 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
public class PlateHeaderNumberingTests
{
[Fact]
public void OnePopulatedPlateWithSentinel_HeaderSaysOneOfOne() => RunSta(() =>
{
var nest = new Nest("one plate");
var plate = nest.CreatePlate();
plate.Parts.Add(Square("a"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
// The constructor's EnsureSentinel keeps the trailing empty new-plate workspace.
Assert.Equal(2, nest.Plates.Count);
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
});
[Fact]
public void NavigatingOntoTheSentinelAndBack_RelabelsWithoutTouchingNavigation() => RunSta(() =>
{
var nest = new Nest("sentinel navigation");
var plate = nest.CreatePlate();
plate.Parts.Add(Square("a"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.True(form.PlateManager.LoadNext()); // onto the sentinel
Assert.Equal("New plate (empty)", Header(form));
Assert.Equal(1, form.PlateManager.CurrentIndex); // navigation itself is unchanged
Assert.True(form.PlateManager.LoadPrevious());
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
Assert.Equal(2, nest.Plates.Count); // the collection was never mutated for display
});
[Fact]
public void SingleEmptyPlate_EditorShowsTheNewPlateView() => RunSta(() =>
{
var nest = new Nest("empty");
using var form = new EditNestForm(nest); // EnsureSentinel creates the single empty plate.
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.Single(nest.Plates);
Assert.Equal("New plate (empty)", Header(form));
});
[Fact]
public void InteriorEmptyPlate_KeepsItsNumberAndTheTotalCountsIt() => RunSta(() =>
{
var nest = new Nest("interior empty");
nest.CreatePlate().Parts.Add(Square("a"));
nest.CreatePlate(); // An empty interior plate survives EnsureSentinel (only the tail trims).
nest.CreatePlate().Parts.Add(Square("b"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.Equal(4, nest.Plates.Count); // three real + sentinel
form.PlateManager.LoadAt(1);
Assert.Equal("Plate 2 of 3", Header(form).Split(" | ")[0]);
form.PlateManager.LoadAt(2);
Assert.Equal("Plate 3 of 3", Header(form).Split(" | ")[0]);
form.PlateManager.LoadAt(3);
Assert.Equal("New plate (empty)", Header(form));
Assert.Equal(4, nest.Plates.Count);
});
private static string Header(EditNestForm form) =>
((Label)typeof(EditNestForm)
.GetField("plateInfoLabel", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(form)!).Text;
private static Part Square(string name)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(0, 10);
program.LineTo(10, 10);
program.LineTo(10, 0);
program.LineTo(0, 0);
return new Part(new Drawing(name, program), new Vector(1, 1));
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromMinutes(3), "The STA test did not complete.");
}
+9 -4
View File
@@ -85,10 +85,15 @@ public partial class CuttingPlanForm : Form
Text = "Plan Cutting — All Plates";
applyButton.Text = "&Apply to All Plates";
}
var activeNumber = all.IndexOf(activePlate) + 1;
previewText = plates.Contains(activePlate)
? $"Plate {activeNumber} of {all.Count}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// "of M" total; plateNumbers keep real collection positions for the batch summary.
var displayCount = PlateDisplayNumbering.DisplayedPlateCount(all);
var activeNumber = PlateDisplayNumbering.DisplayedPlateNumber(all, all.IndexOf(activePlate));
previewText = activeNumber == null
? "New plate (empty) has no parts; plates with parts are listed on the right."
: plates.Contains(activePlate)
? $"Plate {activeNumber} of {displayCount}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
previewLabel.Text = previewText;
ShowSettings();
}
+4 -13
View File
@@ -781,19 +781,10 @@ namespace OpenNest.Forms
{
var plate = PlateManager.CurrentPlate;
if (plate != null)
{
plateInfoLabel.Text = string.Format(
"Plate {0} of {1} | {2}",
PlateManager.CurrentIndex + 1,
PlateManager.Count,
plate.Size
);
}
else
{
plateInfoLabel.Text = "No plates";
}
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// total (and labeled as such); navigation and storage indexes are untouched.
plateInfoLabel.Text = PlateDisplayNumbering.FormatHeader(
Nest.Plates, PlateManager.CurrentIndex, plate.Size.ToString());
btnFirstPlate.Enabled = !PlateManager.IsFirst;
btnPreviousPlate.Enabled = !PlateManager.IsFirst;