diff --git a/OpenNest.Core/PlateDisplayNumbering.cs b/OpenNest.Core/PlateDisplayNumbering.cs
new file mode 100644
index 0000000..e6b761b
--- /dev/null
+++ b/OpenNest.Core/PlateDisplayNumbering.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+
+namespace OpenNest
+{
+ ///
+ /// 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.
+ ///
+ public static class PlateDisplayNumbering
+ {
+ ///
+ /// The number shown for the plate at : real collection
+ /// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
+ /// sentinel and for indexes outside the collection.
+ ///
+ public static int? DisplayedPlateNumber(IList plates, int storageIndex)
+ {
+ if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
+ return null;
+ if (IsTrailingSentinel(plates, storageIndex))
+ return null;
+ return storageIndex + 1;
+ }
+
+ ///
+ /// 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").
+ ///
+ public static int DisplayedPlateCount(IList 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);
+ }
+
+ /// True when is the trailing empty new-plate sentinel.
+ public static bool IsTrailingSentinel(IList 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;
+ }
+
+ /// Header text for the plate at .
+ public static string FormatHeader(IList 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);
+ }
+ }
+}
diff --git a/OpenNest.Tests/PlateDisplayNumberingTests.cs b/OpenNest.Tests/PlateDisplayNumberingTests.cs
new file mode 100644
index 0000000..105735a
--- /dev/null
+++ b/OpenNest.Tests/PlateDisplayNumberingTests.cs
@@ -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;
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
index c405d57..deb5337 100644
--- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
@@ -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