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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-02 09:51:11 -05:00
1 parent e8c52a354d
commit d29f919aa2
11 files changed
+227 -13

No files matched your search

+2
View File
@@ -25,5 +25,7 @@ namespace OpenNest
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
public CutOffSettings Copy() => (CutOffSettings)MemberwiseClone();
}
}
+8
View File
@@ -169,6 +169,13 @@ namespace OpenNest
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// 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.
/// </summary>
public CutOffSettings CutOffSettings { get; set; }
/// <summary>
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
@@ -458,6 +465,7 @@ namespace OpenNest
var separating = new List<Part>();
var counts = new Dictionary<CutOff, int>();
var partCount = Parts.Count;
CutOffSettings = settings?.Copy();
// Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs)
+6 -3
View File
@@ -6,8 +6,9 @@ namespace OpenNest.Data;
internal static class SettingsFile
{
/// <summary>
/// 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.
/// </summary>
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)
+18 -5
View File
@@ -51,15 +51,28 @@ namespace OpenNest
public bool HasSavePath => File.Exists(LastSavePath);
/// <summary>
/// Saves to <paramref name="path"/>. A failed write throws and leaves the nest's name and
/// save path as they were, and any file already at the path untouched.
/// </summary>
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();
}
@@ -0,0 +1,48 @@
namespace OpenNest.IO.Tests;
/// <summary>
/// Saving over a nest file must never leave it truncated: a write that fails part way keeps
/// the previously saved nest.
/// </summary>
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<Exception>(() => 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));
}
}
+17
View File
@@ -682,6 +682,13 @@ namespace OpenNest.IO
public List<PartDto> Parts { get; init; } = new();
public List<CutOffDto> CutOffs { get; init; } = new();
/// <summary>
/// The settings the plate's cut-offs were generated with (<c>Plate.CutOffSettings</c>).
/// Omitted when unknown; older files regenerate with the reader's defaults.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CutOffSettingsDto CutOffSettings { get; init; }
/// <summary>
/// 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; }
+25 -1
View File
@@ -592,6 +592,30 @@ namespace OpenNest.IO
return null;
}
/// <summary>
/// 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.
/// </summary>
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);
+37 -2
View File
@@ -49,10 +49,35 @@ namespace OpenNest.IO
this.nest = nest;
}
/// <summary>
/// Writes beside <paramref name="file"/> and swaps the result in, so a failed write leaves
/// the previously saved nest intact instead of truncated.
/// </summary>
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
@@ -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<Motion>().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()
{
+11 -1
View File
@@ -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;
}
+6 -1
View File
@@ -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