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