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