From b8f319d3e88e4ffd95a281faba4fd73cea8e8132 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 6 Oct 2026 20:58:32 -0400 Subject: [PATCH 01/23] 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. --- OpenNest.Core/PlateDisplayNumbering.cs | 64 ++++++++ OpenNest.Tests/PlateDisplayNumberingTests.cs | 138 ++++++++++++++++++ .../Forms/CuttingPlanFormTests.cs | 25 ++++ .../Forms/PlateHeaderNumberingTests.cs | 96 ++++++++++++ OpenNest/Forms/CuttingPlanForm.cs | 13 +- OpenNest/Forms/EditNestForm.cs | 17 +-- 6 files changed, 336 insertions(+), 17 deletions(-) create mode 100644 OpenNest.Core/PlateDisplayNumbering.cs create mode 100644 OpenNest.Tests/PlateDisplayNumberingTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/PlateHeaderNumberingTests.cs 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