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); + } +}