From 6fc1a0ebdcda30668c9409a2782c971ee3622e03 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 17:11:40 -0400 Subject: [PATCH] fix(core): move an untouched trailing sheet to new nest defaults Changing the nest defaults (Nest Info, Set as default, Edit Plate on an empty sheet) left the editor's trailing empty sheet on the old size and quadrant, so the writer's clamp-preserving empty-sheet rule saved it as a stale extra sheet. PlateManager.ChangePlateDefaults wraps a defaults edit: a trailing empty, cutoff-free sheet that exactly matched the old defaults (and followed their rail with no removed clamps or explicit zones) takes the new defaults; every other empty sheet keeps its configuration and is still written. PM cade6901-5f4a-4503-85bf-0ea681d9ca7f --- OpenNest.Core/PlateManager.cs | 44 ++++ .../ClampRailPersistenceTests.cs | 103 ++++++++ OpenNest.IO/NestWriter.cs | 5 +- OpenNest.Tests/PlateManagerDefaultsTests.cs | 236 ++++++++++++++++++ OpenNest/Forms/EditNestForm.cs | 6 +- docs/nest-file-format.md | 11 +- 6 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 OpenNest.Tests/PlateManagerDefaultsTests.cs diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs index 01d4cd87..a70ca2d6 100644 --- a/OpenNest.Core/PlateManager.cs +++ b/OpenNest.Core/PlateManager.cs @@ -119,6 +119,50 @@ namespace OpenNest SubscribeToTailPlates(); } + /// + /// Applies a nest-defaults edit, moving only an untouched trailing empty sheet + /// that matched the old defaults to the new defaults. + /// + public void ChangePlateDefaults(Action changeDefaults) + { + ArgumentNullException.ThrowIfNull(changeDefaults); + var defaults = nest.PlateDefaults; + var trailing = Count > 0 ? nest.Plates[^1] : null; + // Exact configuration equality, not geometric tolerance: even a small edit + // makes this configured stock rather than the untouched workspace sentinel. + var followsDefaults = trailing != null + && trailing.Parts.Count == 0 + && trailing.CutOffs.Count == 0 + && trailing.Size.Width == defaults.Size.Width + && trailing.Size.Length == defaults.Size.Length + && trailing.Quadrant == defaults.Quadrant + && trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left + && trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom + && trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right + && trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top + && trailing.PartSpacing == defaults.PartSpacing + && trailing.RemovedClamps.Count == 0 + && Equals(trailing.ClampRail, defaults.ClampRail) + && (defaults.ClampRail == null + ? trailing.ClampZones.Count == 0 + : defaults.ClampRail.Matches(trailing)); + + changeDefaults(); + + if (!followsDefaults) + return; + + defaults = nest.PlateDefaults; + trailing.Size = defaults.Size; + trailing.Quadrant = defaults.Quadrant; + trailing.EdgeSpacing = defaults.EdgeSpacing; + trailing.PartSpacing = defaults.PartSpacing; + trailing.SetClampRail(defaults.ClampRail); + PlateListChanged?.Invoke(this, EventArgs.Empty); + if (ReferenceEquals(CurrentPlate, trailing)) + FireCurrentPlateChanged(); + } + public void BeginBatch() { batching = true; diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs index 49e0c8fe..800a6553 100644 --- a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs +++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs @@ -92,6 +92,109 @@ public class ClampRailPersistenceTests Assert.Same(reopened.PlateDefaults.ClampRail, kept.ClampRail); } + [Fact] + public void ChangedDefaultsMoveUntouchedSentinelAndSaveNoEmptySheets() + { + var nest = new Nest("railed"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.PlateDefaults.Quadrant = 1; + nest.SetClampRail(Machine()); + using var manager = new PlateManager(nest); + manager.EnsureSentinel(); + var sentinel = Assert.Single(nest.Plates); + + manager.ChangePlateDefaults(() => + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4); + nest.PlateDefaults.PartSpacing = 0.5; + }); + manager.EnsureSentinel(); + + Assert.Same(sentinel, Assert.Single(nest.Plates)); + using var stream = Write(nest); + using var json = JsonDocument.Parse(ReadNestJson(stream)); + Assert.Equal(0, json.RootElement.GetProperty("plates").GetArrayLength()); + stream.Position = 0; + var reopened = new NestReader(stream).Read(); + Assert.Empty(reopened.Plates); + using var reopenedManager = new PlateManager(reopened); + reopenedManager.EnsureSentinel(); + var newSentinel = Assert.Single(reopened.Plates); + Assert.Equal(new Size(48, 60), newSentinel.Size); + Assert.Equal(3, newSentinel.Quadrant); + Assert.Equal(new Spacing(1, 2, 3, 4), newSentinel.EdgeSpacing); + Assert.Equal(0.5, newSentinel.PartSpacing); + Assert.Same(reopened.PlateDefaults.ClampRail, newSentinel.ClampRail); + Assert.True(newSentinel.ClampRail.Matches(newSentinel)); + Assert.Equal(4, newSentinel.ClampZones.Count); + Assert.Equal(53.25, newSentinel.ClampZones[0].Bounds.X); + Assert.Equal(47.375, newSentinel.ClampZones[0].Bounds.Y); + using var secondSave = Write(reopened); + using var secondJson = JsonDocument.Parse(ReadNestJson(secondSave)); + Assert.Equal(0, secondJson.RootElement.GetProperty("plates").GetArrayLength()); + } + + [Theory] + [InlineData("edited")] + [InlineData("middle")] + [InlineData("removed")] + [InlineData("cutOff")] + [InlineData("explicit")] + public void ChangedDefaultsPreserveAndWriteConfiguredEmptySheets(string configuration) + { + var nest = RailNest(withEmptyTail: false); + var plate = nest.CreatePlate(); + switch (configuration) + { + case "edited": + plate.Size = new Size(48, 84); + plate.Quadrant = 2; + break; + case "middle": + var separator = nest.CreatePlate(); + separator.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + nest.CreatePlate(); + break; + case "removed": + plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" }); + break; + case "cutOff": + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + break; + case "explicit": + plate.SetClampRail(null!); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + } + var oldSize = plate.Size; + var oldQuadrant = plate.Quadrant; + var oldZones = plate.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width)).ToArray(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + }); + + Assert.Equal(oldSize, plate.Size); + Assert.Equal(oldQuadrant, plate.Quadrant); + var reopened = RoundTrip(nest); + Assert.Equal(configuration == "middle" ? 3 : 2, reopened.Plates.Count); + var kept = reopened.Plates[1]; + Assert.Equal(oldSize, kept.Size); + Assert.Equal(oldQuadrant, kept.Quadrant); + Assert.Equal(oldZones, kept.ClampZones.Select(z => (z.Id, z.Bounds.X, z.Bounds.Y, z.Bounds.Length, z.Bounds.Width))); + if (configuration == "removed") + Assert.Equal(new[] { "clamp-1" }, kept.RemovedClamps); + if (configuration == "cutOff") + Assert.Single(kept.CutOffs); + if (configuration == "explicit") + Assert.Null(kept.ClampRail); + } + [Fact] public void SavedZonesThatDifferFromTheRailArePreservedExactly() { diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 2eec5f3a..20e4df94 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -240,8 +240,9 @@ namespace OpenNest.IO var plate = nest.Plates[i]; // An empty plate is omitted unless that would lose a clamp configuration the - // saved defaults cannot recreate; a trailing empty sheet that still matches - // the defaults is dropped. + // saved defaults cannot recreate. PlateManager moves an untouched trailing + // sentinel to new defaults during an edit, so it remains omitted; configured + // nondefault clamp sheets are still written. if ( plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0 diff --git a/OpenNest.Tests/PlateManagerDefaultsTests.cs b/OpenNest.Tests/PlateManagerDefaultsTests.cs new file mode 100644 index 00000000..7426e251 --- /dev/null +++ b/OpenNest.Tests/PlateManagerDefaultsTests.cs @@ -0,0 +1,236 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PlateManagerDefaultsTests +{ + private static Nest CreateNest(bool withRail = true) + { + var nest = new Nest(); + nest.PlateDefaults.Size = new Size(48, 96); + nest.PlateDefaults.Quadrant = 1; + nest.PlateDefaults.EdgeSpacing = new Spacing(1, 2, 3, 4); + nest.PlateDefaults.PartSpacing = 0.25; + if (withRail) + nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 })); + return nest; + } + + private static void ChangeDefaults(Nest nest) + { + nest.PlateDefaults.Size = new Size(48, 60); + nest.PlateDefaults.Quadrant = 3; + nest.PlateDefaults.EdgeSpacing = new Spacing(5, 6, 7, 8); + nest.PlateDefaults.PartSpacing = 0.5; + } + + private static void AssertMatchesDefaults(Nest nest, Plate plate) + { + Assert.Equal(nest.PlateDefaults.Size, plate.Size); + Assert.Equal(nest.PlateDefaults.Quadrant, plate.Quadrant); + Assert.Equal(nest.PlateDefaults.EdgeSpacing, plate.EdgeSpacing); + Assert.Equal(nest.PlateDefaults.PartSpacing, plate.PartSpacing); + Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail); + Assert.Empty(plate.RemovedClamps); + if (plate.ClampRail == null) + Assert.Empty(plate.ClampZones); + else + Assert.True(plate.ClampRail.Matches(plate)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangePlateDefaults_MovesUntouchedTrailingSheet(bool withRail) + { + var nest = CreateNest(withRail); + using var manager = new PlateManager(nest); + manager.EnsureSentinel(); + var sentinel = Assert.Single(nest.Plates); + var listChanges = 0; + var currentChanges = 0; + manager.PlateListChanged += (_, _) => listChanges++; + manager.CurrentPlateChanged += (_, e) => + { + Assert.Same(sentinel, e.Plate); + currentChanges++; + }; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Same(sentinel, Assert.Single(nest.Plates)); + AssertMatchesDefaults(nest, sentinel); + Assert.Equal(0, manager.CurrentIndex); + Assert.Equal(1, listChanges); + Assert.Equal(1, currentChanges); + if (withRail) + { + // Independent physical oracle: Q3 origin clamp is at the sheet's upper right. + var zone = Assert.Single(sentinel.ClampZones); + Assert.Equal("clamp-1", zone.Id); + Assert.Equal(53.25, zone.Bounds.X); + Assert.Equal(47.375, zone.Bounds.Y); + Assert.Equal(1.5, zone.Bounds.Length); + Assert.Equal(0.625, zone.Bounds.Width); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangePlateDefaults_ReappliesTheNewRail(bool removeRail) + { + var nest = CreateNest(); + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + var newRail = removeRail ? null : new ClampRail(2, 1, new[] { 10.0 }); + + manager.ChangePlateDefaults(() => + { + ChangeDefaults(nest); + nest.PlateDefaults.ClampRail = newRail; + }); + + AssertMatchesDefaults(nest, sentinel); + } + + [Theory] + [InlineData("width")] + [InlineData("length")] + [InlineData("quadrant")] + [InlineData("left")] + [InlineData("bottom")] + [InlineData("right")] + [InlineData("top")] + [InlineData("partSpacing")] + [InlineData("removed")] + [InlineData("explicit")] + [InlineData("detachedMatchingZones")] + [InlineData("differentRail")] + [InlineData("editedRailZones")] + [InlineData("cutOff")] + [InlineData("part")] + public void ChangePlateDefaults_PreservesEditedOrUsedTrailingSheet(string edit) + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + switch (edit) + { + // Even sub-tolerance changes are edits, not untouched defaults. + case "width": plate.Size = new Size(48 + 1e-9, 96); break; + case "length": plate.Size = new Size(48, 96 + 1e-9); break; + case "quadrant": plate.Quadrant = 2; break; + case "left": plate.EdgeSpacing.Left += 1e-9; break; + case "bottom": plate.EdgeSpacing.Bottom += 1e-9; break; + case "right": plate.EdgeSpacing.Right += 1e-9; break; + case "top": plate.EdgeSpacing.Top += 1e-9; break; + case "partSpacing": plate.PartSpacing += 1e-9; break; + case "removed": plate.SetClampRail(nest.PlateDefaults.ClampRail, new[] { "clamp-1" }); break; + case "explicit": + plate.SetClampRail(null!); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + case "detachedMatchingZones": + var zones = plate.ClampZones.ToArray(); + plate.SetClampRail(null!); + foreach (var zone in zones) + plate.ClampZones.Add(zone); + break; + case "differentRail": + plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 81.0 }, removalMinutes: 8)); + break; + case "editedRailZones": + plate.ClampZones.Clear(); + plate.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1))); + break; + case "cutOff": plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); break; + case "part": plate.Parts.Add(new Part(new Drawing("block", new Program()))); break; + } + var size = plate.Size; + var quadrant = plate.Quadrant; + var spacing = plate.EdgeSpacing; + var partSpacing = plate.PartSpacing; + var rail = plate.ClampRail; + var removed = plate.RemovedClamps.ToArray(); + var savedZones = plate.ClampZones.ToArray(); + var parts = plate.Parts.ToArray(); + var cutOffs = plate.CutOffs.ToArray(); + using var manager = new PlateManager(nest); + var notifications = 0; + manager.CurrentPlateChanged += (_, _) => notifications++; + manager.PlateListChanged += (_, _) => notifications++; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Equal(new Size(48, 60), nest.PlateDefaults.Size); + Assert.Same(plate, Assert.Single(nest.Plates)); + Assert.Equal(size, plate.Size); + Assert.Equal(quadrant, plate.Quadrant); + Assert.Equal(spacing, plate.EdgeSpacing); + Assert.Equal(partSpacing, plate.PartSpacing); + Assert.Same(rail, plate.ClampRail); + Assert.Equal(removed, plate.RemovedClamps); + Assert.Equal(savedZones, plate.ClampZones); + Assert.Equal(parts, plate.Parts); + Assert.Equal(cutOffs, plate.CutOffs); + Assert.Equal(0, notifications); + } + + [Fact] + public void ChangePlateDefaults_PreservesMiddleEmptySheetAndCurrentSelection() + { + var nest = CreateNest(); + var middle = nest.CreatePlate(); + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + manager.LoadFirst(); + var currentChanges = 0; + manager.CurrentPlateChanged += (_, _) => currentChanges++; + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Equal(2, nest.Plates.Count); + Assert.Equal(new Size(48, 96), middle.Size); + Assert.Equal(1, middle.Quadrant); + Assert.Equal(new Spacing(1, 2, 3, 4), middle.EdgeSpacing); + Assert.Equal(0.25, middle.PartSpacing); + Assert.Equal(2, middle.ClampZones.Count); + AssertMatchesDefaults(nest, sentinel); + Assert.Same(middle, manager.CurrentPlate); + Assert.Equal(0, currentChanges); + } + + [Fact] + public void ChangePlateDefaults_SetFromExistingMovesOnlyTheSentinel() + { + var nest = CreateNest(); + var selected = nest.CreatePlate(); + selected.Size = new Size(48, 60); + selected.Quadrant = 3; + selected.EdgeSpacing = new Spacing(5, 6, 7, 8); + selected.PartSpacing = 0.5; + var sentinel = nest.CreatePlate(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => nest.PlateDefaults.SetFromExisting(selected)); + + AssertMatchesDefaults(nest, sentinel); + AssertMatchesDefaults(nest, selected); + Assert.Equal(2, nest.Plates.Count); + Assert.Same(selected, manager.CurrentPlate); + } + + [Fact] + public void ChangePlateDefaults_WithoutPlatesStillAppliesDefaults() + { + var nest = CreateNest(); + using var manager = new PlateManager(nest); + + manager.ChangePlateDefaults(() => ChangeDefaults(nest)); + + Assert.Empty(nest.Plates); + manager.EnsureSentinel(); + AssertMatchesDefaults(nest, Assert.Single(nest.Plates)); + } +} diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 635f1517..342dc3e3 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -739,7 +739,7 @@ namespace OpenNest.Forms if (form.ShowDialog() == System.Windows.Forms.DialogResult.OK) { - form.SaveNestInfo(Nest); + PlateManager.ChangePlateDefaults(() => form.SaveNestInfo(Nest)); drawingListBox1.Units = Nest.Units; drawingListBox1.Invalidate(); } @@ -808,7 +808,7 @@ namespace OpenNest.Forms public void SetCurrentPlateAsNestDefault() { - Nest.PlateDefaults.SetFromExisting(PlateView.Plate); + PlateManager.ChangePlateDefaults(() => Nest.PlateDefaults.SetFromExisting(PlateView.Plate)); } public void EditPlate() @@ -827,7 +827,7 @@ namespace OpenNest.Forms drawingListBox1.Refresh(); if (PlateView.Plate.Parts.Count == 0) - Nest.PlateDefaults.SetFromExisting(PlateView.Plate); + PlateManager.ChangePlateDefaults(() => Nest.PlateDefaults.SetFromExisting(PlateView.Plate)); } /// diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index d454cede..2040d3ff 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -92,9 +92,14 @@ An empty plate (no parts, no cut-offs) is omitted from the file unless that would lose clamp zones: it is omitted like any other empty plate only when a new plate from the saved defaults would recreate it (same size and quadrant, zones the saved rail recreates), which keeps the editor's trailing empty sheet -out of the file while it still matches the defaults. An empty plate with other -zones, or a rail-following one whose size or quadrant differs from the defaults -(including a trailing sheet created before the defaults changed), is written. +out of the file while it matches the defaults. Nest-defaults edits move only an +untouched trailing empty, cutoff-free sheet to the new size, quadrant, edge and +part spacing, re-resolving its rail. It must have exactly matched the old defaults +and followed their rail with no removed clamps or explicit zones (or have had no +rail and no zones when the defaults had none). Edited sheets, middle empty sheets, +and sheets with parts, cut-offs, removed clamps or explicit zones are not changed. +An empty plate with other zones, or a rail-following one whose size or quadrant +differs from the saved defaults, is still written. ## Drawing edits