From d29f919aa2b78be79fdb7c16490babb01b49d983 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 2 Nov 2026 09:43:21 -0500 Subject: [PATCH] fix(io): save nests safely and reopen them with their cut-off settings Reopening a nest regenerated its cut-offs with built-in defaults (no overtravel, a different minimum segment) rather than the machine's settings it was made with, so a nest saved, reopened and posted could cut scrap that never separates. Plates now record the settings their cut-offs were last generated with, and files carry them; older files still load with the defaults. Saving over a nest file truncated it before writing, so any failure part way lost the only good copy, and the error reached the user as an unhandled exception after the save path had already changed. Nest and settings files are now written beside the target and swapped in, and a failed save is reported with the old file left intact. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/CutOffSettings.cs | 2 + OpenNest.Core/Plate.cs | 8 +++ OpenNest.Data/SettingsFile.cs | 9 ++-- OpenNest.Desktop/Document.cs | 23 +++++++-- .../NestWriterAtomicSaveTests.cs | 48 ++++++++++++++++++ OpenNest.IO/NestFormat.cs | 17 +++++++ OpenNest.IO/NestReader.cs | 26 +++++++++- OpenNest.IO/NestWriter.cs | 39 ++++++++++++++- .../CutOffs/CutOffSerializationTests.cs | 49 +++++++++++++++++++ OpenNest/Forms/EditNestForm.cs | 12 ++++- docs/nest-file-format.md | 7 ++- 11 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 OpenNest.IO.Tests/NestWriterAtomicSaveTests.cs diff --git a/OpenNest.Core/CutOffSettings.cs b/OpenNest.Core/CutOffSettings.cs index cf7bc182..5f56cacc 100644 --- a/OpenNest.Core/CutOffSettings.cs +++ b/OpenNest.Core/CutOffSettings.cs @@ -25,5 +25,7 @@ namespace OpenNest /// The head-locked overcut a new cut-off gets from these settings; zero when off. public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0; + + public CutOffSettings Copy() => (CutOffSettings)MemberwiseClone(); } } diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 8037e139..5d55624e 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -169,6 +169,13 @@ namespace OpenNest /// public ObservableList CutOffs { get; set; } + /// + /// A copy of the settings the cut-offs were last regenerated with, or null before the + /// first regeneration. Saved with the nest, so reopening it rebuilds the same cut-off + /// geometry rather than geometry from built-in defaults. + /// + public CutOffSettings CutOffSettings { get; set; } + /// /// Fixed rectangular machine-clamp keep-outs in plate coordinates /// (). Never parts: excluded from quantity, utilization, @@ -458,6 +465,7 @@ namespace OpenNest var separating = new List(); var counts = new Dictionary(); var partCount = Parts.Count; + CutOffSettings = settings?.Copy(); // Regenerate and materialize each cut-off's segments foreach (var cutoff in CutOffs) diff --git a/OpenNest.Data/SettingsFile.cs b/OpenNest.Data/SettingsFile.cs index d34dbcd4..6500e866 100644 --- a/OpenNest.Data/SettingsFile.cs +++ b/OpenNest.Data/SettingsFile.cs @@ -6,8 +6,9 @@ namespace OpenNest.Data; internal static class SettingsFile { /// - /// Writes the file, creating the parent directory, and retries briefly on IO collisions. - /// The last failure propagates; the host handles a persistent write failure. + /// Writes the file through a temporary file swapped into place, so a failed write keeps + /// the previous settings, creating the parent directory and retrying briefly on IO + /// collisions. The last failure propagates; the host handles a persistent write failure. /// public static void Write(string path, string json, int maxRetries = 3) { @@ -19,7 +20,9 @@ internal static class SettingsFile { if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - File.WriteAllText(path, json); + var temp = path + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); return; } catch (IOException) when (attempt < maxRetries - 1) diff --git a/OpenNest.Desktop/Document.cs b/OpenNest.Desktop/Document.cs index cff023b6..29a6a1d1 100644 --- a/OpenNest.Desktop/Document.cs +++ b/OpenNest.Desktop/Document.cs @@ -51,15 +51,28 @@ namespace OpenNest public bool HasSavePath => File.Exists(LastSavePath); + /// + /// Saves to . A failed write throws and leaves the nest's name and + /// save path as they were, and any file already at the path untouched. + /// public void SaveAs(string path) { - var name = Path.GetFileNameWithoutExtension(path); - Nest.Name = name; + var previousName = Nest.Name; + Nest.Name = Path.GetFileNameWithoutExtension(path); + try + { + var writer = new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }; + if (!writer.Write(path)) + throw new IOException($"The nest could not be written to {path}."); + } + catch + { + Nest.Name = previousName; + throw; + } + LastSaveDate = DateTime.Now; LastSavePath = path; - - var writer = new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }; - writer.Write(path); if (History.Current != null) History.MarkSaved(); } diff --git a/OpenNest.IO.Tests/NestWriterAtomicSaveTests.cs b/OpenNest.IO.Tests/NestWriterAtomicSaveTests.cs new file mode 100644 index 00000000..d81180d2 --- /dev/null +++ b/OpenNest.IO.Tests/NestWriterAtomicSaveTests.cs @@ -0,0 +1,48 @@ +namespace OpenNest.IO.Tests; + +/// +/// Saving over a nest file must never leave it truncated: a write that fails part way keeps +/// the previously saved nest. +/// +public class NestWriterAtomicSaveTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-atomic-save-" + Guid.NewGuid()); + + public NestWriterAtomicSaveTests() => Directory.CreateDirectory(directory); + + public void Dispose() => Directory.Delete(directory, recursive: true); + + [Fact] + public void FailedWrite_KeepsThePreviousFile() + { + var path = Path.Combine(directory, "job.nest"); + var nest = new Nest("Good"); + var drawing = TestDrawings.Square(); + nest.Drawings.Add(drawing); + var plate = new Plate(48, 96); + plate.Parts.Add(new Part(drawing)); + nest.Plates.Add(plate); + Assert.True(new NestWriter(nest).Write(path)); + var saved = File.ReadAllBytes(path); + + // JSON has no NaN, so serializing this plate throws part way through the archive. + nest.Plates[0].PartSpacing = double.NaN; + Assert.ThrowsAny(() => new NestWriter(nest).Write(path)); + + Assert.Equal(saved, File.ReadAllBytes(path)); + Assert.Equal(new[] { path }, Directory.GetFiles(directory)); + } + + [Fact] + public void Write_ReplacesAnExistingFile() + { + var path = Path.Combine(directory, "job.nest"); + File.WriteAllText(path, "old"); + + Assert.True(new NestWriter(new Nest("New")).Write(path)); + + using var stream = File.OpenRead(path); + Assert.Equal("New", new NestReader(stream).Read().Name); + Assert.Equal(new[] { path }, Directory.GetFiles(directory)); + } +} diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index cbf6df15..55bbb0e1 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -682,6 +682,13 @@ namespace OpenNest.IO public List Parts { get; init; } = new(); public List CutOffs { get; init; } = new(); + /// + /// The settings the plate's cut-offs were generated with (Plate.CutOffSettings). + /// Omitted when unknown; older files regenerate with the reader's defaults. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CutOffSettingsDto CutOffSettings { get; init; } + /// /// Machine clamp keep-out rectangles in plate coordinates. Missing in older /// files, which load with no zones. @@ -964,6 +971,16 @@ namespace OpenNest.IO public CutOffGapDto[] Gaps { get; init; } } + public record CutOffSettingsDto + { + public double PartClearance { get; init; } + public double Overtravel { get; init; } + public double MinSegmentLength { get; init; } + public string CutDirection { get; init; } + public bool LockHeadHeight { get; init; } + public double HeadLockOvercut { get; init; } + } + public record CutOffGapDto { public double Start { get; init; } diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index dcbcc9e2..2f950724 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -592,6 +592,30 @@ namespace OpenNest.IO return null; } + /// + /// The saved cut-off settings, field by field over the defaults: a missing, negative or + /// nonfinite length keeps its default rather than generating different geometry. + /// + private static CutOffSettings ReadCutOffSettings(CutOffSettingsDto dto) + { + var settings = new CutOffSettings(); + if (dto == null) + return settings; + static bool Valid(double value) => double.IsFinite(value) && value >= 0; + if (Valid(dto.PartClearance)) + settings.PartClearance = dto.PartClearance; + if (Valid(dto.Overtravel)) + settings.Overtravel = dto.Overtravel; + if (Valid(dto.MinSegmentLength)) + settings.MinSegmentLength = dto.MinSegmentLength; + if (string.Equals(dto.CutDirection, "towardOrigin", StringComparison.OrdinalIgnoreCase)) + settings.CutDirection = CutDirection.TowardOrigin; + settings.LockHeadHeight = dto.LockHeadHeight; + if (Valid(dto.HeadLockOvercut)) + settings.HeadLockOvercut = dto.HeadLockOvercut; + return settings; + } + private static SupportBed ReadSupportBed(SupportBedDto dto) { if (dto == null) @@ -812,7 +836,7 @@ namespace OpenNest.IO firstPlaces[cutoff] = index; } - plate.RegenerateCutOffs(new CutOffSettings(), places, firstPlaces); + plate.RegenerateCutOffs(ReadCutOffSettings(p.CutOffSettings), places, firstPlaces); } nest.Plates.Add(plate); diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index de13b9bf..6b4f4a98 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -49,10 +49,35 @@ namespace OpenNest.IO this.nest = nest; } + /// + /// Writes beside and swaps the result in, so a failed write leaves + /// the previously saved nest intact instead of truncated. + /// public bool Write(string file) { - using var fileStream = new FileStream(file, FileMode.Create); - return Write(fileStream); + var temp = file + ".tmp"; + try + { + bool written; + using (var fileStream = new FileStream(temp, FileMode.Create)) + written = Write(fileStream); + if (!written) + { + File.Delete(temp); + return false; + } + File.Move(temp, file, overwrite: true); + return true; + } + catch + { + try + { + File.Delete(temp); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + throw; + } } public bool Write(Stream stream) @@ -448,6 +473,16 @@ namespace OpenNest.IO }, Parts = parts, CutOffs = cutoffs, + CutOffSettings = cutoffs.Count == 0 || plate.CutOffSettings is not { } settings ? null + : new CutOffSettingsDto + { + PartClearance = settings.PartClearance, + Overtravel = settings.Overtravel, + MinSegmentLength = settings.MinSegmentLength, + CutDirection = settings.CutDirection == CutDirection.TowardOrigin ? "towardOrigin" : "awayFromOrigin", + LockHeadHeight = settings.LockHeadHeight, + HeadLockOvercut = settings.HeadLockOvercut, + }, GrainAngle = plate.GrainAngle, CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters), Clamps = plate.ClampZones diff --git a/OpenNest.Tests/CutOffs/CutOffSerializationTests.cs b/OpenNest.Tests/CutOffs/CutOffSerializationTests.cs index 28de8a95..d39ddee1 100644 --- a/OpenNest.Tests/CutOffs/CutOffSerializationTests.cs +++ b/OpenNest.Tests/CutOffs/CutOffSerializationTests.cs @@ -80,6 +80,55 @@ public class CutOffSerializationTests Assert.Equal(2, CutOffSegments.Of(loaded, loaded.CutOffs[0]).Count); } + [Fact] + public void RoundTrip_RegeneratesWithTheSavedSettingsNotTheDefaults() + { + var nest = new Nest(); + var plate = TestHelpers.MakePlate(48, 96); + nest.Plates.Add(plate); + plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical)); + var machine = new CutOffSettings + { + Overtravel = 0.75, + MinSegmentLength = 0.5, + PartClearance = 0.1, + CutDirection = CutDirection.TowardOrigin, + }; + plate.RegenerateCutOffs(machine); + var before = Program(plate); + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest) { KeepEmptyPlates = true }.Write(stream)); + stream.Position = 0; + var loaded = Assert.Single(new NestReader(stream).Read().Plates); + + // The defaults have no overtravel, so a reader using them would not cut through. + Assert.Equal(before, Program(loaded)); + Assert.Equal(0.75, loaded.CutOffSettings.Overtravel); + Assert.Equal(CutDirection.TowardOrigin, loaded.CutOffSettings.CutDirection); + + static string Program(Plate plate) => string.Join(";", plate.Parts.Where(p => p.BaseDrawing.IsCutOff) + .SelectMany(p => p.Program.Codes).OfType().Select(m => m.EndPoint.ToString())); + } + + [Fact] + public void Read_WithoutSavedSettings_UsesTheDefaults() + { + var nest = new Nest(); + var plate = TestHelpers.MakePlate(48, 96); + nest.Plates.Add(plate); + plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings { Overtravel = 0.75 }); + plate.CutOffSettings = null; + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest) { KeepEmptyPlates = true }.Write(stream)); + stream.Position = 0; + var loaded = Assert.Single(new NestReader(stream).Read().Plates); + + Assert.Equal(0, loaded.CutOffSettings.Overtravel); + } + [Fact] public void RoundTrip_CutOffsPreserved() { diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index cff37f58..1ec08db5 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -599,7 +599,17 @@ namespace OpenNest.Forms public void SaveAs(string path) { - Document.SaveAs(path); + try + { + Document.SaveAs(path); + } + catch (Exception ex) + { + MessageBox.Show(this, + $"The nest was not saved. Any copy already at that path was left as it was.\n\n{path}\n\n{ex.Message}", + "Save Nest", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } Text = Document.Name; } diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index f81d9ab5..776bf1b2 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -79,7 +79,12 @@ Each serialized plate's `cutOffs` array holds its cut-off definitions: `x`, `y`, `axis` (`vertical` or `horizontal`), optional `startLimit` and `endLimit`, and the places in the plate's cut sequence (`Plate.Parts` order, cut-offs included) of the parts the definition materializes. Loading regenerates each definition with the -reader's default cut-off settings and makes one part per trimmed segment. `sequence` +plate's `cutOffSettings` and makes one part per trimmed segment. That object (`partClearance`, +`overtravel`, `minSegmentLength`, `cutDirection` as `awayFromOrigin` or `towardOrigin`, +`lockHeadHeight`, `headLockOvercut`) records the settings the cut-offs were last generated +with, so a reopened nest posts the cut-off geometry that was reviewed. It is omitted when the +plate has no cut-offs or the settings are unknown; older files, and any missing, negative or +nonfinite length, fall back to the reader's defaults. `sequence` is the zero-based place of the first segment (by position along the line); null or absent in older files, which load the cut-off at the end. `segments` lists the place of every segment in ascending order along the line; it is always written (files from