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