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