From 81e3e58f13c74d64220bbe689a1d005d4b6f71dc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 18:19:47 -0400 Subject: [PATCH] feat(data): clamp settings store Persist the machine clamp rail in %APPDATA%\OpenNest\clamps.json, in the same shape as the nest defaults store: camelCase JSON written with retry, and a read that never throws. Clamps are off by default: a missing or unreadable file keeps them off, and nothing changes until the settings are enabled with at least one center. Each value is read independently; an invalid one (including a single bad clamp center) is ignored and reported as Partial so the desktop can warn that the rail may lack a physical clamp. Values carry their units and convert between inches and millimeters for a new nest. --- OpenNest.Data/ClampSettings.cs | 307 ++++++++++++++++++++++ OpenNest.Tests/Data/ClampSettingsTests.cs | 285 ++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 OpenNest.Data/ClampSettings.cs create mode 100644 OpenNest.Tests/Data/ClampSettingsTests.cs diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs new file mode 100644 index 0000000..5192019 --- /dev/null +++ b/OpenNest.Data/ClampSettings.cs @@ -0,0 +1,307 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// Outcome of . +public enum ClampSettingsStatus +{ + /// Every value present in the file was valid. + Ok, + + /// No file exists at the path; clamps stay off. + Missing, + + /// + /// The file loaded, but some values were invalid and were ignored (an invalid clamp + /// center is dropped, other fields fall back individually). Callers should warn: the + /// resulting rail may lack a physical clamp. + /// + Partial, + + /// The file exists but could not be read or parsed; clamps stay off. + Invalid, +} + +/// +/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default +/// %APPDATA%\OpenNest\clamps.json). Off by default: until is set +/// with at least one center, new nests get no clamps and behave exactly as before. +/// Loading never throws; values are validated field by field. +/// +public sealed class ClampSettings +{ + public const int CurrentVersion = 1; + + /// Default clamp extent along the edge, in (inches). + public const double DefaultWidth = 1.5; + + /// Default clamp intrusion into the sheet, in (inches). + public const double DefaultDepth = 0.625; + + private const double MillimetersPerInch = 25.4; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// Whether new nests receive this rail. Default off. + public bool Enabled { get; set; } + + /// Units of , and . + public Units Units { get; set; } = Units.Inches; + + public double Width { get; set; } = DefaultWidth; + + public double Depth { get; set; } = DefaultDepth; + + /// Clamp centers along X, measured from the plate origin. + public IReadOnlyList Centers { get; set; } = Array.Empty(); + + /// The built-in settings used for a missing or unreadable file: clamps off. + public static ClampSettings Fallback => new(); + + /// %APPDATA%\OpenNest\clamps.json. + public static string DefaultPath => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", + "clamps.json" + ); + + /// + /// Captures a nest's rail (or the default clamp size and no centers when it has none) + /// in that nest's units, e.g. for "save as machine defaults". + /// + public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) => + new() + { + Enabled = enabled, + Units = units, + Width = rail?.Width ?? ConvertDefault(DefaultWidth, units), + Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units), + Centers = rail?.Centers.ToArray() ?? Array.Empty(), + }; + + /// + /// The rail a new nest in receives, or null when clamps are + /// disabled or no center is configured. Values are converted between inches and + /// millimeters as needed. + /// + public ClampRail? RailFor(Units units) + { + if (!Enabled || Centers.Count == 0) + return null; + + var scale = Scale(Units, units); + return new ClampRail(Width * scale, Depth * scale, Centers.Select(c => c * scale)); + } + + /// + /// Gives a newly created nest this machine rail when enabled (after the nest's units are + /// set); otherwise leaves it untouched. + /// + public void ApplyToNewNest(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + var rail = RailFor(nest.Units); + if (rail != null) + nest.SetClampRail(rail); + } + + public static ClampSettings Load(string path) => Load(path, out _); + + /// + /// Loads the settings, never throwing. A missing or unreadable file yields + /// (clamps off); a readable file merges valid values over it and + /// reports if anything was ignored. + /// + public static ClampSettings Load(string path, out ClampSettingsStatus status) + { + var settings = Fallback; + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + status = ClampSettingsStatus.Missing; + return settings; + } + + ClampSettingsDto? dto; + try + { + dto = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions); + } + catch (Exception ex) when (IsUnreadableFile(ex)) + { + status = ClampSettingsStatus.Invalid; + return settings; + } + + if (dto is null) + { + status = ClampSettingsStatus.Invalid; + return settings; + } + + var ignored = false; + + if (dto.Enabled is { } enabled) + { + if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) + settings.Enabled = enabled.GetBoolean(); + else + ignored = true; + } + + if (dto.Units is { } units) + { + if (TryParseUnits(units, out var parsed)) + settings.Units = parsed; + else + ignored = true; + } + + if (dto.Width is { } width) + { + if (TryPositive(width, out var value)) + settings.Width = value; + else + ignored = true; + } + + if (dto.Depth is { } depth) + { + if (TryPositive(depth, out var value)) + settings.Depth = value; + else + ignored = true; + } + + if (dto.Centers is { } centers) + { + if (centers.ValueKind == JsonValueKind.Array) + { + var valid = new List(); + foreach (var center in centers.EnumerateArray()) + { + if ( + center.ValueKind == JsonValueKind.Number + && center.TryGetDouble(out var x) + && double.IsFinite(x) + && x >= 0 + ) + valid.Add(x); + else + ignored = true; + } + settings.Centers = valid.Distinct().OrderBy(x => x).ToArray(); + } + else + { + ignored = true; + } + } + + status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok; + return settings; + } + + /// + /// 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 + { + version = CurrentVersion, + enabled = Enabled, + units = Units.ToString().ToLowerInvariant(), + width = Width, + depth = Depth, + centers = Centers, + }; + 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 double Scale(Units from, Units to) => + from == to ? 1 + : from == Units.Inches ? MillimetersPerInch + : 1 / MillimetersPerInch; + + private static double ConvertDefault(double inches, Units units) => + inches * Scale(Units.Inches, units); + + private static bool TryPositive(JsonElement element, out double value) + { + value = 0; + return element.ValueKind == JsonValueKind.Number + && element.TryGetDouble(out value) + && double.IsFinite(value) + && value > 0; + } + + /// + /// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept + /// numeric strings and comma-joined names. + /// + private static bool TryParseUnits(JsonElement element, out Units units) + { + units = default; + if (element.ValueKind != JsonValueKind.String) + return false; + + var text = element.GetString(); + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase)) + { + units = candidate; + return true; + } + } + + return false; + } + + private static bool IsUnreadableFile(Exception ex) => + ex + is JsonException + or IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException; + + /// + /// Wire format. Every value is read as raw JSON so a wrongly typed field falls back on + /// its own instead of failing the whole file; unknown fields (including a future higher + /// version) are ignored. + /// + private sealed record ClampSettingsDto + { + public JsonElement? Version { get; init; } + public JsonElement? Enabled { get; init; } + public JsonElement? Units { get; init; } + public JsonElement? Width { get; init; } + public JsonElement? Depth { get; init; } + public JsonElement? Centers { get; init; } + } +} diff --git a/OpenNest.Tests/Data/ClampSettingsTests.cs b/OpenNest.Tests/Data/ClampSettingsTests.cs new file mode 100644 index 0000000..c90bf30 --- /dev/null +++ b/OpenNest.Tests/Data/ClampSettingsTests.cs @@ -0,0 +1,285 @@ +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Data; + +/// +/// The machine clamp settings store (%APPDATA%\OpenNest\clamps.json): off by default, a +/// never-throwing read that degrades value by value, and no effect until enabled. +/// +public class ClampSettingsTests : IDisposable +{ + private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 }; + + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + private readonly string _path; + + public ClampSettingsTests() + { + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "clamps.json"); + } + + public void Dispose() + { + if (!Directory.Exists(_directory)) + return; + if (!OperatingSystem.IsWindows() && File.Exists(_path)) + File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + Directory.Delete(_directory, recursive: true); + } + + private static ClampSettings Machine(bool enabled = true) => new() + { + Enabled = enabled, + Width = 1.5, + Depth = 0.625, + Centers = MachineCenters, + }; + + private static void AssertFallback(ClampSettings settings) + { + Assert.False(settings.Enabled); + Assert.Equal(Units.Inches, settings.Units); + Assert.Equal(1.5, settings.Width); + Assert.Equal(0.625, settings.Depth); + Assert.Empty(settings.Centers); + Assert.Null(settings.RailFor(Units.Inches)); + } + + [Fact] + public void DefaultPathUsesTheApplicationDataOpenNestDirectory() + { + Assert.Equal( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", "clamps.json"), + ClampSettings.DefaultPath); + } + + [Fact] + public void MissingFileKeepsClampsOff() + { + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Missing, status); + AssertFallback(loaded); + Assert.Equal(ClampSettingsStatus.Missing, LoadStatus("")); + } + + [Fact] + public void SaveThenLoadRoundTripsAsCamelCaseJson() + { + Machine().Save(_path); + + var loaded = ClampSettings.Load(_path, out var status); + using var json = JsonDocument.Parse(File.ReadAllText(_path)); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.True(loaded.Enabled); + Assert.Equal(Units.Inches, loaded.Units); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(MachineCenters, loaded.Centers); + var root = json.RootElement; + Assert.Equal(1, root.GetProperty("version").GetInt32()); + Assert.True(root.GetProperty("enabled").GetBoolean()); + Assert.Equal("inches", root.GetProperty("units").GetString()); + Assert.Equal(8, root.GetProperty("centers").GetArrayLength()); + } + + [Fact] + public void SaveCreatesTheSettingsDirectory() + { + var nested = Path.Combine(_directory, "AppData", "OpenNest", "clamps.json"); + + Machine().Save(nested); + + Assert.True(ClampSettings.Load(nested).Enabled); + } + + [Fact] + public void NothingChangesUntilEnabled() + { + var nest = new Nest(); + Machine(enabled: false).ApplyToNewNest(nest); + + Assert.Null(Machine(enabled: false).RailFor(Units.Inches)); + Assert.Null(nest.PlateDefaults.ClampRail); + Assert.Empty(nest.CreatePlate().ClampZones); + + // Enabled without any clamp center is still no clamps. + var empty = Machine(); + empty.Centers = Array.Empty(); + Assert.Null(empty.RailFor(Units.Inches)); + } + + [Fact] + public void EnabledSettingsGiveANewNestTheMachineRail() + { + var nest = new Nest { Units = Units.Inches }; + nest.PlateDefaults.Size = new Size(48, 60); + + Machine().ApplyToNewNest(nest); + var plate = nest.CreatePlate(); + + Assert.Equal(new ClampRail(1.5, 0.625, MachineCenters), nest.PlateDefaults.ClampRail); + Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, plate.ClampZones.Select(z => z.Bounds.X)); + } + + [Fact] + public void RailIsConvertedToTheNestsUnits() + { + var metric = Machine().RailFor(Units.Millimeters)!; + Assert.Equal(1.5 * 25.4, metric.Width, 12); + Assert.Equal(0.625 * 25.4, metric.Depth, 12); + Assert.Equal(6 * 25.4, metric.Centers[0], 12); + + var settings = new ClampSettings + { + Enabled = true, + Units = Units.Millimeters, + Width = 38.1, + Depth = 15.875, + Centers = new[] { 152.4 }, + }; + var inches = settings.RailFor(Units.Inches)!; + Assert.Equal(1.5, inches.Width, 12); + Assert.Equal(6, inches.Centers[0], 12); + Assert.Equal(38.1, settings.RailFor(Units.Millimeters)!.Width); + } + + [Fact] + public void PartialFileMergesOverTheDefaults() + { + File.WriteAllText(_path, """{ "enabled": true, "centers": [21, 6] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.True(loaded.Enabled); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(new[] { 6.0, 21 }, loaded.Centers); + } + + [Fact] + public void InvalidValuesFallBackIndividuallyAndAreReported() + { + File.WriteAllText(_path, """ + { + "enabled": "yes", + "units": "7", + "width": -1, + "depth": "deep", + "centers": [6, "x", -3, 21, 1e400, 21], + "future": { "ignored": true } + } + """); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.False(loaded.Enabled); + Assert.Equal(Units.Inches, loaded.Units); + Assert.Equal(1.5, loaded.Width); + Assert.Equal(0.625, loaded.Depth); + Assert.Equal(new[] { 6.0, 21 }, loaded.Centers); + } + + [Theory] + [InlineData("""{ "centers": 6 }""")] + [InlineData("""{ "centers": [6, -1] }""")] + [InlineData("""{ "centers": [6, "21"] }""")] + [InlineData("""{ "units": ["inches"] }""")] + [InlineData("""{ "enabled": 1 }""")] + [InlineData("""{ "width": 0 }""")] + public void AnySingleInvalidValueIsReported(string json) + { + File.WriteAllText(_path, json); + Assert.Equal(ClampSettingsStatus.Partial, LoadStatus(_path)); + } + + [Fact] + public void AnInvalidCenterIsDroppedButTheRestOfTheRailStillLoads() + { + File.WriteAllText(_path, """{ "enabled": true, "centers": [6, "21", 36] }"""); + + var loaded = ClampSettings.Load(_path, out var status); + + // The operator must be warned: the rail is now missing a physical clamp. + Assert.Equal(ClampSettingsStatus.Partial, status); + Assert.True(loaded.Enabled); + Assert.Equal(new[] { 6.0, 36 }, loaded.Centers); + } + + [Theory] + [InlineData("{ this is not json")] + [InlineData("[6, 21, 36]")] + [InlineData("null")] + [InlineData("")] + public void UnparseableFileKeepsClampsOffAndIsReported(string json) + { + File.WriteAllText(_path, json); + + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Invalid, status); + AssertFallback(loaded); + } + + [SkippableFact] + public void UnreadableFileKeepsClampsOffAndIsReported() + { + if (OperatingSystem.IsWindows()) + throw new SkipException("Unix file modes deny the read on Linux/macOS"); + + Machine().Save(_path); + File.SetUnixFileMode(_path, UnixFileMode.None); + Skip.If(CanRead(_path), "Process can read a mode-000 file (running as root)"); + + Assert.True(File.Exists(_path)); + var loaded = ClampSettings.Load(_path, out var status); + + Assert.Equal(ClampSettingsStatus.Invalid, status); + AssertFallback(loaded); + } + + [Fact] + public void FromRailCapturesANestRailForMachineDefaults() + { + var rail = new ClampRail(38.1, 15.875, new[] { 152.4, 533.4 }); + + var captured = ClampSettings.FromRail(rail, Units.Millimeters, enabled: true); + + Assert.True(captured.Enabled); + Assert.Equal(Units.Millimeters, captured.Units); + Assert.Equal(38.1, captured.Width); + Assert.Equal(15.875, captured.Depth); + Assert.Equal(new[] { 152.4, 533.4 }, captured.Centers); + Assert.Equal(rail, captured.RailFor(Units.Millimeters)); + + var none = ClampSettings.FromRail(null, Units.Inches, enabled: false); + AssertFallback(none); + } + + private static ClampSettingsStatus LoadStatus(string path) + { + ClampSettings.Load(path, out var status); + return status; + } + + private static bool CanRead(string path) + { + try + { + using var _ = File.OpenRead(path); + return true; + } + catch (UnauthorizedAccessException) + { + return false; + } + } +}