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