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
This commit is contained in:
aj committed 2026-10-13 18:15:50 -04:00
1 parent 6b995ac40a
commit 6fc1a0ebdc
6 files changed
+397 -8

No files matched your search

+44
View File
@@ -119,6 +119,50 @@ namespace OpenNest
SubscribeToTailPlates();
}
/// <summary>
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
/// that matched the old defaults to the new defaults.
/// </summary>
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;
@@ -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()
{
+3 -2
View File
@@ -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
+236
View File
@@ -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));
}
}
+3 -3
View File
@@ -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));
}
/// <summary>
+8 -3
View File
@@ -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