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;
///
/// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
/// the machine clamp settings alongside the rail's physical size.
///
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// Origin-side clamp centers along X, measured from the plate origin.
public IReadOnlyList Centers { get; set; } = Array.Empty();
/// Far-side clamp centers along X, measured from the plate origin.
public IReadOnlyList FarSideCenters { get; set; } = Array.Empty();
/// Origin-end clamp centers along Y, measured from the plate origin.
public IReadOnlyList OriginEndCenters { get; set; } = Array.Empty();
/// Far-end clamp centers along Y, measured from the plate origin.
public IReadOnlyList FarEndCenters { get; set; } = Array.Empty();
/// Every configured clamp, by edge then center as listed.
public IEnumerable Clamps =>
Centers.Select(c => new RailClamp(ClampEdge.OriginSide, c))
.Concat(FarSideCenters.Select(c => new RailClamp(ClampEdge.FarSide, c)))
.Concat(OriginEndCenters.Select(c => new RailClamp(ClampEdge.OriginEnd, c)))
.Concat(FarEndCenters.Select(c => new RailClamp(ClampEdge.FarEnd, c)));
/// The number of configured clamps on every edge.
public int ClampCount =>
Centers.Count + FarSideCenters.Count + OriginEndCenters.Count + FarEndCenters.Count;
/// The centers listed for .
public IReadOnlyList CentersOn(ClampEdge edge) =>
edge switch
{
ClampEdge.FarSide => FarSideCenters,
ClampEdge.OriginEnd => OriginEndCenters,
ClampEdge.FarEnd => FarEndCenters,
_ => Centers,
};
/// Lists under their edges, replacing every edge's centers.
public void SetClamps(IEnumerable clamps)
{
var list = clamps.ToList();
double[] On(ClampEdge edge) => list.Where(c => c.Edge == edge).Select(c => c.Center).ToArray();
Centers = On(ClampEdge.OriginSide);
FarSideCenters = On(ClampEdge.FarSide);
OriginEndCenters = On(ClampEdge.OriginEnd);
FarEndCenters = On(ClampEdge.FarEnd);
}
/// 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)
{
var settings = new ClampSettings
{
Enabled = enabled,
Units = units,
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
};
if (rail != null)
settings.SetClamps(rail.Clamps);
return settings;
}
///
/// 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; is a time, so it is never scaled.
///
public ClampRail? RailFor(Units units)
{
if (!Enabled || ClampCount == 0)
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
}
///
/// A copy with width, depth and centers expressed in
/// (inches and millimeters convert at 25.4); and
/// (a time, not a length) are kept unscaled.
///
public ClampSettings ConvertedTo(Units units)
{
var scale = Scale(Units, units);
return new ClampSettings
{
Enabled = Enabled,
Units = units,
Width = Width * scale,
Depth = Depth * scale,
RemovalMinutes = RemovalMinutes,
Centers = Centers.Select(c => c * scale).ToArray(),
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
FarEndCenters = FarEndCenters.Select(c => c * scale).ToArray(),
};
}
///
/// 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;
}
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
// present but unusable one, including an explicit null (fallback, Partial).
Dictionary values;
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
return settings;
}
values = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var property in document.RootElement.EnumerateObject())
values[property.Name] = property.Value.Clone();
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return settings;
}
var ignored = false;
if (values.TryGetValue("enabled", out var enabled))
{
if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
settings.Enabled = enabled.GetBoolean();
else
ignored = true;
}
if (values.TryGetValue("units", out var units))
{
if (TryParseUnits(units, out var parsed))
settings.Units = parsed;
else
ignored = true;
}
// Fallback sizes are the physical default clamp, expressed in the file's units.
settings.Width = ConvertDefault(DefaultWidth, settings.Units);
settings.Depth = ConvertDefault(DefaultDepth, settings.Units);
if (values.TryGetValue("width", out var width))
{
if (TryLength(width, out var value) && value > 0)
settings.Width = value;
else
ignored = true;
}
if (values.TryGetValue("depth", out var depth))
{
if (TryLength(depth, out var value) && value > 0)
settings.Depth = value;
else
ignored = true;
}
if (values.TryGetValue("removalMinutes", out var removalMinutes))
{
if (TryLength(removalMinutes, out var value) && value >= 0)
settings.RemovalMinutes = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers))
settings.Centers = ReadCenters(centers, ref ignored);
if (values.TryGetValue("farSideCenters", out var farSide))
settings.FarSideCenters = ReadCenters(farSide, ref ignored);
if (values.TryGetValue("originEndCenters", out var originEnd))
settings.OriginEndCenters = ReadCenters(originEnd, ref ignored);
if (values.TryGetValue("farEndCenters", out var farEnd))
settings.FarEndCenters = ReadCenters(farEnd, ref ignored);
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
return settings;
}
/// Valid centers of a JSON array, distinct and ascending; flags anything dropped.
private static double[] ReadCenters(JsonElement centers, ref bool ignored)
{
if (centers.ValueKind != JsonValueKind.Array)
{
ignored = true;
return Array.Empty();
}
var valid = new List();
foreach (var center in centers.EnumerateArray())
{
if (TryLength(center, out var x) && x >= 0)
valid.Add(x);
else
ignored = true;
}
return valid.Distinct().OrderBy(x => x).ToArray();
}
///
/// 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 Dictionary
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
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);
///
/// A finite JSON number that stays finite in either unit, so applying it to an inch or a
/// millimeter nest can never overflow.
///
private static bool TryLength(JsonElement element, out double value)
{
value = 0;
return element.ValueKind == JsonValueKind.Number
&& element.TryGetDouble(out value)
&& double.IsFinite(value)
&& double.IsFinite(value * MillimetersPerInch);
}
///
/// 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;
}