diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs
new file mode 100644
index 0000000..3424b05
--- /dev/null
+++ b/OpenNest.Data/NestDefaults.cs
@@ -0,0 +1,233 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.Geometry;
+
+namespace OpenNest.Data;
+
+///
+/// Plate/nest defaults persisted to a single JSON file
+/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
+/// .nstdot nest-template mechanism. Loading never throws: a missing,
+/// corrupt, or partially valid file degrades field-by-field to
+/// values so creating a new nest is never blocked.
+///
+public sealed class NestDefaults
+{
+ public const int CurrentVersion = 1;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ public Units Units { get; set; } = Units.Inches;
+
+ public Size Size { get; set; } = new(100, 100);
+
+ public int Quadrant { get; set; } = 1;
+
+ public double PartSpacing { get; set; } = 1;
+
+ public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
+
+ ///
+ /// The built-in defaults used when no file exists and for every field
+ /// that is missing or invalid. Matches the historical
+ /// MainForm.CreateDefaultNest values (units default to Inches; callers
+ /// may override from their own settings).
+ ///
+ public static NestDefaults Fallback => new();
+
+ /// %APPDATA%\OpenNest\defaults.json.
+ public static string DefaultPath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest",
+ "defaults.json"
+ );
+
+ ///
+ /// Loads defaults from , falling back field by
+ /// field for a missing file, invalid JSON, or invalid values.
+ ///
+ public static NestDefaults Load(string path) => Load(path, out _);
+
+ ///
+ /// Loads defaults; is true when the file
+ /// was present (even if it failed to parse), so callers can surface a
+ /// warning when a present file did not load cleanly.
+ ///
+ public static NestDefaults Load(string path, out bool fileExisted)
+ {
+ fileExisted = false;
+ var defaults = Fallback;
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return defaults;
+
+ fileExisted = true;
+
+ NestDefaultsDto? dto;
+ try
+ {
+ var json = File.ReadAllText(path);
+ dto = JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return defaults;
+ }
+ catch (IOException)
+ {
+ return defaults;
+ }
+
+ if (dto is null)
+ return defaults;
+
+ if (
+ dto.Units is not null
+ && Enum.TryParse(dto.Units, ignoreCase: true, out var units)
+ )
+ defaults.Units = units;
+
+ if (
+ dto.Size?.Width is { } width
+ && dto.Size.Length is { } length
+ && IsValidSize(width, length)
+ )
+ defaults.Size = new Size(width, length);
+
+ if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
+ defaults.Quadrant = quadrant;
+
+ if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
+ defaults.PartSpacing = partSpacing;
+
+ if (
+ dto.EdgeSpacing?.Left is { } left
+ && dto.EdgeSpacing.Bottom is { } bottom
+ && dto.EdgeSpacing.Right is { } right
+ && dto.EdgeSpacing.Top is { } top
+ && IsValidSpacing(left)
+ && IsValidSpacing(bottom)
+ && IsValidSpacing(right)
+ && IsValidSpacing(top)
+ )
+ defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
+
+ return defaults;
+ }
+
+ ///
+ /// Captures the current units and plate defaults from a nest.
+ ///
+ public static NestDefaults FromNest(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var plate = nest.PlateDefaults;
+ return new NestDefaults
+ {
+ Units = nest.Units,
+ Size = plate.Size,
+ Quadrant = plate.Quadrant,
+ PartSpacing = plate.PartSpacing,
+ EdgeSpacing = plate.EdgeSpacing,
+ };
+ }
+
+ public void ApplyTo(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ nest.Units = Units;
+ var plate = nest.PlateDefaults;
+ plate.Size = Size;
+ plate.Quadrant = Quadrant;
+ plate.PartSpacing = PartSpacing;
+ plate.EdgeSpacing = EdgeSpacing;
+ }
+
+ ///
+ /// Writes the file (creating the parent directory), retrying briefly on
+ /// IO collisions the same way does.
+ ///
+ public void Save(string path, int maxRetries = 3)
+ {
+ var dto = new NestDefaultsDto
+ {
+ Version = CurrentVersion,
+ Units = Units.ToString().ToLowerInvariant(),
+ Size = new SizeDto { Width = Size.Width, Length = Size.Length },
+ Quadrant = Quadrant,
+ PartSpacing = PartSpacing,
+ EdgeSpacing = new SpacingDto
+ {
+ Left = EdgeSpacing.Left,
+ Bottom = EdgeSpacing.Bottom,
+ Right = EdgeSpacing.Right,
+ Top = EdgeSpacing.Top,
+ },
+ };
+
+ var json = JsonSerializer.Serialize(dto, JsonOptions);
+
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ for (var attempt = 0; attempt < maxRetries; attempt++)
+ {
+ try
+ {
+ File.WriteAllText(path, json);
+ return;
+ }
+ catch (IOException) when (attempt < maxRetries - 1)
+ {
+ Thread.Sleep(100);
+ }
+ }
+ }
+
+ private static bool IsValidSize(double width, double length) =>
+ !double.IsNaN(width)
+ && !double.IsNaN(length)
+ && !double.IsInfinity(width)
+ && !double.IsInfinity(length)
+ && width > 0
+ && length > 0;
+
+ private static bool IsValidSpacing(double value) =>
+ !double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
+
+ ///
+ /// Wire format. Every field is nullable so a partial file merges over
+ /// the fallback field by field; unknown fields (including a future
+ /// higher version) are ignored rather than rejected.
+ ///
+ private sealed record NestDefaultsDto
+ {
+ public int? Version { get; init; } = CurrentVersion;
+ public string? Units { get; init; }
+ public SizeDto? Size { get; init; }
+ public int? Quadrant { get; init; }
+ public double? PartSpacing { get; init; }
+ public SpacingDto? EdgeSpacing { get; init; }
+ }
+
+ private sealed record SizeDto
+ {
+ public double? Width { get; init; }
+ public double? Length { get; init; }
+ }
+
+ private sealed record SpacingDto
+ {
+ public double? Left { get; init; }
+ public double? Bottom { get; init; }
+ public double? Right { get; init; }
+ public double? Top { get; init; }
+ }
+}
diff --git a/OpenNest.Tests/Data/NestDefaultsTests.cs b/OpenNest.Tests/Data/NestDefaultsTests.cs
new file mode 100644
index 0000000..baec274
--- /dev/null
+++ b/OpenNest.Tests/Data/NestDefaultsTests.cs
@@ -0,0 +1,208 @@
+using OpenNest.Data;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Data;
+
+public class NestDefaultsTests : IDisposable
+{
+ private readonly string _testDir;
+ private readonly string _path;
+
+ public NestDefaultsTests()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(_testDir);
+ _path = Path.Combine(_testDir, "defaults.json");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, true);
+ }
+
+ [Fact]
+ public void Save_ThenLoad_RoundTrips()
+ {
+ var original = new NestDefaults
+ {
+ Units = Units.Millimeters,
+ Size = new Size(1220, 2440),
+ Quadrant = 3,
+ PartSpacing = 2.5,
+ EdgeSpacing = new Spacing(1.5, 3, 1.5, 3),
+ };
+
+ original.Save(_path);
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.True(loadedFromDisk);
+ Assert.Equal(Units.Millimeters, loaded.Units);
+ Assert.Equal(original.Size, loaded.Size);
+ Assert.Equal(3, loaded.Quadrant);
+ Assert.Equal(2.5, loaded.PartSpacing);
+ Assert.Equal(original.EdgeSpacing.Left, loaded.EdgeSpacing.Left);
+ Assert.Equal(original.EdgeSpacing.Bottom, loaded.EdgeSpacing.Bottom);
+ Assert.Equal(original.EdgeSpacing.Right, loaded.EdgeSpacing.Right);
+ Assert.Equal(original.EdgeSpacing.Top, loaded.EdgeSpacing.Top);
+ }
+
+ [Fact]
+ public void Save_CreatesMissingDirectory()
+ {
+ var nested = Path.Combine(_testDir, "nested", "defaults.json");
+ new NestDefaults().Save(nested);
+ Assert.True(File.Exists(nested));
+ }
+
+ [Fact]
+ public void Load_MissingFile_ReturnsFallback()
+ {
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.False(loadedFromDisk);
+ AssertFallback(loaded);
+ }
+
+ [Fact]
+ public void Load_CorruptJson_ReturnsFallbackButReportsFile()
+ {
+ File.WriteAllText(_path, "{ this is not json");
+
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.True(loadedFromDisk);
+ AssertFallback(loaded);
+ }
+
+ [Fact]
+ public void Load_PartialFile_MergesPerField()
+ {
+ File.WriteAllText(_path, """{ "units": "millimeters", "partSpacing": 4 }""");
+
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.True(loadedFromDisk);
+ Assert.Equal(Units.Millimeters, loaded.Units);
+ Assert.Equal(4, loaded.PartSpacing);
+ // Untouched fields keep fallback values.
+ Assert.Equal(new Size(100, 100), loaded.Size);
+ Assert.Equal(1, loaded.Quadrant);
+ Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
+ }
+
+ [Fact]
+ public void Load_OutOfRangeValues_FallBackPerField()
+ {
+ File.WriteAllText(
+ _path,
+ """
+ {
+ "units": "furlongs",
+ "size": { "width": -50, "length": 100 },
+ "quadrant": 9,
+ "partSpacing": -1,
+ "edgeSpacing": { "left": 1, "bottom": -2, "right": 1, "top": 1 }
+ }
+ """
+ );
+
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.True(loadedFromDisk);
+ Assert.Equal(Units.Inches, loaded.Units);
+ Assert.Equal(new Size(100, 100), loaded.Size);
+ Assert.Equal(1, loaded.Quadrant);
+ Assert.Equal(1, loaded.PartSpacing);
+ Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
+ }
+
+ [Fact]
+ public void Load_NonFiniteValues_FallBack()
+ {
+ File.WriteAllText(_path, """{ "partSpacing": 1e400 }""");
+
+ var loaded = NestDefaults.Load(_path);
+
+ // 1e400 deserializes to Infinity, which is rejected.
+ Assert.Equal(1, loaded.PartSpacing);
+ }
+
+ [Fact]
+ public void Load_UnknownFieldsAndFutureVersion_Ignored()
+ {
+ File.WriteAllText(
+ _path,
+ """
+ {
+ "version": 2,
+ "units": "inches",
+ "partSpacing": 2,
+ "futureFeature": { "enabled": true },
+ "material": { "name": "steel" }
+ }
+ """
+ );
+
+ var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
+
+ Assert.True(loadedFromDisk);
+ Assert.Equal(2, loaded.PartSpacing);
+ }
+
+ [Fact]
+ public void FromNest_CapturesUnitsAndPlateDefaults()
+ {
+ var nest = new Nest
+ {
+ Units = Units.Millimeters,
+ };
+ nest.PlateDefaults.Size = new Size(60, 120);
+ nest.PlateDefaults.Quadrant = 2;
+ nest.PlateDefaults.PartSpacing = 0.5;
+ nest.PlateDefaults.EdgeSpacing = new Spacing(2, 2, 2, 2);
+
+ var captured = NestDefaults.FromNest(nest);
+
+ Assert.Equal(Units.Millimeters, captured.Units);
+ Assert.Equal(new Size(60, 120), captured.Size);
+ Assert.Equal(2, captured.Quadrant);
+ Assert.Equal(0.5, captured.PartSpacing);
+ Assert.Equal(new Spacing(2, 2, 2, 2), captured.EdgeSpacing);
+ }
+
+ [Fact]
+ public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
+ {
+ var source = new Nest();
+ source.PlateDefaults.Size = new Size(48, 96);
+ source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
+ var defaults = NestDefaults.FromNest(source);
+
+ // Mutating the source nest afterwards must not change the capture.
+ source.PlateDefaults.Size = new Size(1, 1);
+ source.PlateDefaults.EdgeSpacing = new Spacing(9, 9, 9, 9);
+
+ var target = new Nest();
+ defaults.ApplyTo(target);
+
+ Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
+ Assert.Equal(
+ new Spacing(1.25, 1.25, 1.25, 1.25),
+ target.PlateDefaults.EdgeSpacing
+ );
+
+ // And the applied target owns its own values too.
+ target.PlateDefaults.Size = new Size(2, 2);
+ Assert.Equal(new Size(48, 96), defaults.Size);
+ }
+
+ private static void AssertFallback(NestDefaults loaded)
+ {
+ Assert.Equal(Units.Inches, loaded.Units);
+ Assert.Equal(new Size(100, 100), loaded.Size);
+ Assert.Equal(1, loaded.Quadrant);
+ Assert.Equal(1, loaded.PartSpacing);
+ Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
+ }
+}