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