diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs
index 9d57219b..776f2de2 100644
--- a/OpenNest.Data/ClampSettings.cs
+++ b/OpenNest.Data/ClampSettings.cs
@@ -23,9 +23,11 @@ public enum ClampSettingsStatus
}
///
-/// 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.
+/// An edge-clamp rail in given units, as a machine gives new nests (see
+/// ) and as the legacy single
+/// %APPDATA%\OpenNest\clamps.json held it before each machine kept its own; that file is now
+/// read only to copy it onto a machine once (). Off by default:
+/// until is set with at least one center, new nests get no clamps.
/// Loading never throws; values are validated field by field.
///
public sealed class ClampSettings
diff --git a/OpenNest.Data/LegacyClampMigration.cs b/OpenNest.Data/LegacyClampMigration.cs
new file mode 100644
index 00000000..e4a2d254
--- /dev/null
+++ b/OpenNest.Data/LegacyClampMigration.cs
@@ -0,0 +1,32 @@
+namespace OpenNest.Data;
+
+///
+/// Moves the single legacy clamps.json (or its shared copy) onto a machine once. The legacy
+/// rail is copied only while no machine has clamps of its own; after that it is never read.
+///
+public static class LegacyClampMigration
+{
+ ///
+ /// Whether (null when there is no legacy file) still has to be
+ /// copied: it lists at least one clamp and none of has clamps yet.
+ ///
+ public static bool IsNeeded(ClampSettings? legacy, IReadOnlyCollection machines) =>
+ legacy is { ClampCount: > 0 } && machines.Count > 0 && machines.All(m => m.Clamps == null);
+
+ ///
+ /// The machine that takes the legacy rail without asking: the nest defaults' machine when it
+ /// is on file, otherwise the only machine. Null when there are several and none is the
+ /// default, so the user has to choose.
+ ///
+ public static MachineConfig? Target(IReadOnlyCollection machines, Guid? defaultMachineId) =>
+ machines.FirstOrDefault(m => m.Id == defaultMachineId)
+ ?? (machines.Count == 1 ? machines.First() : null);
+
+ /// Gives the legacy rail, converted to the machine's units.
+ public static void Apply(ClampSettings legacy, MachineConfig machine)
+ {
+ ArgumentNullException.ThrowIfNull(legacy);
+ ArgumentNullException.ThrowIfNull(machine);
+ machine.Clamps = MachineClamps.FromSettings(legacy, machine.Units);
+ }
+}
diff --git a/OpenNest.Data/MachineClamps.cs b/OpenNest.Data/MachineClamps.cs
new file mode 100644
index 00000000..806ab687
--- /dev/null
+++ b/OpenNest.Data/MachineClamps.cs
@@ -0,0 +1,85 @@
+namespace OpenNest.Data;
+
+///
+/// A machine's fixed edge-clamp rail, stored on its in the machine's
+/// units. New nests assigned the machine copy it as their own rail; editing it never changes an
+/// existing nest. validates the stored values field by field.
+///
+public sealed class MachineClamps
+{
+ /// Whether new nests assigned this machine receive the rail. Default off.
+ public bool Enabled { get; set; }
+
+ /// Clamp extent along its edge.
+ public double Width { get; set; } = ClampSettings.DefaultWidth;
+
+ /// Clamp intrusion into the sheet from its edge.
+ public double Depth { get; set; } = ClampSettings.DefaultDepth;
+
+ /// Minutes to remove one clamp and put it back after cutting; never scaled.
+ public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
+
+ /// Origin-side clamp centers along X, measured from the plate origin.
+ public List Centers { get; set; } = new();
+
+ /// Far-side clamp centers along X, measured from the plate origin.
+ public List FarSideCenters { get; set; } = new();
+
+ /// Origin-end clamp centers along Y, measured from the plate origin.
+ public List OriginEndCenters { get; set; } = new();
+
+ /// Far-end clamp centers along Y, measured from the plate origin.
+ public List FarEndCenters { get; set; } = new();
+
+ /// The matching a machine's .
+ public static Units UnitsOf(UnitSystem units) =>
+ units == UnitSystem.Millimeters ? Units.Millimeters : Units.Inches;
+
+ ///
+ /// converted to a machine in , e.g. for
+ /// "Save as Machine Defaults" from a nest in other units or the clamps.json migration.
+ ///
+ public static MachineClamps FromSettings(ClampSettings settings, UnitSystem units)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ var converted = settings.ConvertedTo(UnitsOf(units));
+ return new MachineClamps
+ {
+ Enabled = converted.Enabled,
+ Width = converted.Width,
+ Depth = converted.Depth,
+ RemovalMinutes = converted.RemovalMinutes,
+ Centers = converted.Centers.ToList(),
+ FarSideCenters = converted.FarSideCenters.ToList(),
+ OriginEndCenters = converted.OriginEndCenters.ToList(),
+ FarEndCenters = converted.FarEndCenters.ToList(),
+ };
+ }
+
+ ///
+ /// The rail as clamp settings in the machine's . An invalid size or
+ /// removal time falls back to the default, and an invalid center is dropped, as when
+ /// reads a file.
+ ///
+ public ClampSettings ToSettings(UnitSystem units)
+ {
+ var defaults = ClampSettings.FromRail(null, UnitsOf(units), Enabled);
+ if (IsLength(Width) && Width > 0)
+ defaults.Width = Width;
+ if (IsLength(Depth) && Depth > 0)
+ defaults.Depth = Depth;
+ if (IsLength(RemovalMinutes) && RemovalMinutes >= 0)
+ defaults.RemovalMinutes = RemovalMinutes;
+ defaults.Centers = Valid(Centers);
+ defaults.FarSideCenters = Valid(FarSideCenters);
+ defaults.OriginEndCenters = Valid(OriginEndCenters);
+ defaults.FarEndCenters = Valid(FarEndCenters);
+ return defaults;
+ }
+
+ private static double[] Valid(IEnumerable? centers) =>
+ (centers ?? Enumerable.Empty()).Where(c => IsLength(c) && c >= 0).Distinct().Order().ToArray();
+
+ // Finite in either unit, so converting to an inch or millimeter nest cannot overflow.
+ private static bool IsLength(double value) => double.IsFinite(value) && double.IsFinite(value * 25.4);
+}
diff --git a/OpenNest.Data/MachineConfig.cs b/OpenNest.Data/MachineConfig.cs
index 7cf1671b..3859b984 100644
--- a/OpenNest.Data/MachineConfig.cs
+++ b/OpenNest.Data/MachineConfig.cs
@@ -20,8 +20,18 @@ public class MachineConfig
///
public SupportGridSettings? SupportGrid { get; set; }
+ ///
+ /// The edge-clamp rail new nests assigned this machine receive, in ; null
+ /// until set (the machine then gives nests no clamps).
+ ///
+ public MachineClamps? Clamps { get; set; }
+
public MotionConfig GetMotionOrPlaceholder() => Motion ?? MotionConfig.Placeholder(Units);
+ /// The machine's clamps in its units; off, at the default clamp size, when it has none.
+ public ClampSettings GetClampSettings() =>
+ Clamps?.ToSettings(Units) ?? ClampSettings.FromRail(null, MachineClamps.UnitsOf(Units), enabled: false);
+
public ThicknessConfig? GetParameters(string material, double thickness) =>
GetParameters(material, null, thickness);
diff --git a/OpenNest.Data/SharedSettings.cs b/OpenNest.Data/SharedSettings.cs
index fa0e9f5f..490ebc68 100644
--- a/OpenNest.Data/SharedSettings.cs
+++ b/OpenNest.Data/SharedSettings.cs
@@ -40,7 +40,10 @@ public static class SharedSettingKinds
/// A post processor's settings, keyed by the post's type name.
public const string Post = "post";
- /// The new nests start from, under .
+ ///
+ /// The legacy new nests started from before each machine held its
+ /// clamps, under ; read only to copy them onto a machine once.
+ ///
public const string Clamps = "clamps";
/// The new nests start from, under .
diff --git a/OpenNest.Tests/Data/MachineClampsTests.cs b/OpenNest.Tests/Data/MachineClampsTests.cs
new file mode 100644
index 00000000..7ee73984
--- /dev/null
+++ b/OpenNest.Tests/Data/MachineClampsTests.cs
@@ -0,0 +1,230 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// Each machine's own clamp rail (, in the machine's units),
+/// the rail new nests copy from it, and the one-time copy of the legacy clamps.json onto a machine.
+///
+public class MachineClampsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+
+ public MachineClampsTests() => Directory.CreateDirectory(_directory);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ Directory.Delete(_directory, recursive: true);
+ }
+
+ private static MachineClamps Rail(bool enabled = true) => new()
+ {
+ Enabled = enabled,
+ Width = 1.5,
+ Depth = 0.625,
+ RemovalMinutes = 3,
+ Centers = new List { 6, 21, 36 },
+ FarEndCenters = new List { 10 },
+ };
+
+ private static ClampSettings Legacy(Units units = Units.Inches) => new()
+ {
+ Enabled = true,
+ Units = units,
+ Width = 2,
+ Depth = 0.5,
+ RemovalMinutes = 4,
+ Centers = new double[] { 12, 24 },
+ };
+
+ [Fact]
+ public void SaveMachine_RoundTripsTheClamps()
+ {
+ var provider = new LocalJsonProvider(_directory);
+ var machine = new MachineConfig { Name = "Laser", Clamps = Rail() };
+
+ provider.SaveMachine(machine);
+ var clamps = provider.GetMachine(machine.Id)!.Clamps!;
+
+ Assert.True(clamps.Enabled);
+ Assert.Equal(1.5, clamps.Width);
+ Assert.Equal(0.625, clamps.Depth);
+ Assert.Equal(3, clamps.RemovalMinutes);
+ Assert.Equal(new double[] { 6, 21, 36 }, clamps.Centers);
+ Assert.Empty(clamps.FarSideCenters);
+ Assert.Equal(new double[] { 10 }, clamps.FarEndCenters);
+ }
+
+ [Fact]
+ public void MachineSavedBeforeClamps_LoadsWithoutClamps()
+ {
+ var provider = new LocalJsonProvider(_directory);
+ var machine = new MachineConfig { Name = "Laser" };
+ provider.SaveMachine(machine);
+
+ var loaded = provider.GetMachine(machine.Id)!;
+
+ Assert.Null(loaded.Clamps);
+ Assert.Null(loaded.GetClampSettings().RailFor(Units.Inches));
+ }
+
+ [Fact]
+ public void MachineWithoutClamps_GivesNoRailAtTheDefaultSizeInItsUnits()
+ {
+ var machine = new MachineConfig { Units = UnitSystem.Millimeters };
+
+ var settings = machine.GetClampSettings();
+
+ Assert.False(settings.Enabled);
+ Assert.Equal(Units.Millimeters, settings.Units);
+ Assert.Equal(ClampSettings.DefaultWidth * 25.4, settings.Width, 9);
+ Assert.Equal(0, settings.ClampCount);
+ }
+
+ [Fact]
+ public void DisabledClamps_GiveNewNestsNoRail()
+ {
+ var machine = new MachineConfig { Clamps = Rail(enabled: false) };
+ var nest = new Nest { Units = Units.Inches };
+
+ machine.GetClampSettings().ApplyToNewNest(nest);
+
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ }
+
+ [Fact]
+ public void MillimeterMachine_GivesAnInchNestItsRailConverted()
+ {
+ var machine = new MachineConfig
+ {
+ Units = UnitSystem.Millimeters,
+ Clamps = new MachineClamps
+ {
+ Enabled = true,
+ Width = 38.1,
+ Depth = 12.7,
+ RemovalMinutes = 2,
+ Centers = new List { 254 },
+ },
+ };
+ var nest = new Nest { Units = Units.Inches };
+
+ machine.GetClampSettings().ApplyToNewNest(nest);
+
+ var rail = nest.PlateDefaults.ClampRail!;
+ Assert.Equal(1.5, rail.Width, 9);
+ Assert.Equal(0.5, rail.Depth, 9);
+ Assert.Equal(2, rail.RemovalMinutes);
+ Assert.Equal(10, Assert.Single(rail.Clamps).Center, 9);
+ }
+
+ [Fact]
+ public void EditingTheMachine_NeverMovesAnExistingNestsClamps()
+ {
+ var machine = new MachineConfig { Clamps = Rail() };
+ var nest = new Nest { Units = Units.Inches };
+ machine.GetClampSettings().ApplyToNewNest(nest);
+
+ machine.Clamps!.Centers = new List { 50 };
+ machine.Clamps.Width = 3;
+
+ var rail = nest.PlateDefaults.ClampRail!;
+ Assert.Equal(1.5, rail.Width);
+ Assert.Equal(new double[] { 6, 21, 36 },
+ rail.Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center));
+ }
+
+ [Fact]
+ public void InvalidStoredValues_FallBackOrAreDropped()
+ {
+ var clamps = new MachineClamps
+ {
+ Enabled = true,
+ Width = -1,
+ Depth = double.NaN,
+ RemovalMinutes = -2,
+ Centers = new List { 30, -5, double.PositiveInfinity, 10, 30 },
+ FarSideCenters = null!,
+ };
+
+ var settings = clamps.ToSettings(UnitSystem.Inches);
+
+ Assert.Equal(ClampSettings.DefaultWidth, settings.Width);
+ Assert.Equal(ClampSettings.DefaultDepth, settings.Depth);
+ Assert.Equal(ClampRail.DefaultRemovalMinutes, settings.RemovalMinutes);
+ Assert.Equal(new double[] { 10, 30 }, settings.Centers);
+ Assert.Empty(settings.FarSideCenters);
+ }
+
+ [Fact]
+ public void FromSettings_ConvertsToTheMachineUnits()
+ {
+ var clamps = MachineClamps.FromSettings(Legacy(), UnitSystem.Millimeters);
+
+ Assert.True(clamps.Enabled);
+ Assert.Equal(50.8, clamps.Width, 9);
+ Assert.Equal(12.7, clamps.Depth, 9);
+ Assert.Equal(4, clamps.RemovalMinutes);
+ Assert.Equal(new[] { 304.8, 609.6 }, clamps.Centers.Select(c => System.Math.Round(c, 9)));
+ }
+
+ [Fact]
+ public void Migration_IsNeededOnlyWhileNoMachineHasClamps()
+ {
+ var a = new MachineConfig { Name = "A" };
+ var b = new MachineConfig { Name = "B" };
+
+ Assert.True(LegacyClampMigration.IsNeeded(Legacy(), new[] { a, b }));
+ Assert.False(LegacyClampMigration.IsNeeded(null, new[] { a, b }));
+ Assert.False(LegacyClampMigration.IsNeeded(new ClampSettings { Enabled = true }, new[] { a, b }));
+ Assert.False(LegacyClampMigration.IsNeeded(Legacy(), Array.Empty()));
+
+ LegacyClampMigration.Apply(Legacy(), a);
+
+ Assert.False(LegacyClampMigration.IsNeeded(Legacy(), new[] { a, b }));
+ }
+
+ [Fact]
+ public void Migration_TargetsTheDefaultMachine()
+ {
+ var a = new MachineConfig { Name = "A" };
+ var b = new MachineConfig { Name = "B" };
+
+ Assert.Same(b, LegacyClampMigration.Target(new[] { a, b }, b.Id));
+ }
+
+ [Fact]
+ public void Migration_TargetsTheOnlyMachine()
+ {
+ var only = new MachineConfig { Name = "Only" };
+
+ Assert.Same(only, LegacyClampMigration.Target(new[] { only }, null));
+ Assert.Same(only, LegacyClampMigration.Target(new[] { only }, Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Migration_WithSeveralMachinesAndNoDefault_LeavesTheChoiceToTheUser()
+ {
+ var machines = new[] { new MachineConfig { Name = "A" }, new MachineConfig { Name = "B" } };
+
+ Assert.Null(LegacyClampMigration.Target(machines, null));
+ Assert.Null(LegacyClampMigration.Target(machines, Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Migration_CopiesTheLegacyFileIntoTheMachineUnits()
+ {
+ var path = Path.Combine(_directory, "clamps.json");
+ Legacy().Save(path);
+ var machine = new MachineConfig { Units = UnitSystem.Millimeters };
+
+ LegacyClampMigration.Apply(ClampSettings.Load(path), machine);
+
+ var rail = machine.GetClampSettings().RailFor(Units.Inches)!;
+ Assert.Equal(2, rail.Width, 9);
+ Assert.Equal(4, rail.RemovalMinutes);
+ Assert.Equal(new[] { 12.0, 24.0 }, rail.Clamps.Select(c => System.Math.Round(c.Center, 9)));
+ Assert.Equal(new[] { 304.8, 609.6 }, machine.Clamps!.Centers.Select(c => System.Math.Round(c, 9)));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/MachineClampsFormsTests.cs b/OpenNest.WinForms.Tests/Forms/MachineClampsFormsTests.cs
new file mode 100644
index 00000000..96482832
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/MachineClampsFormsTests.cs
@@ -0,0 +1,110 @@
+using System.Windows.Forms;
+using OpenNest.Data;
+using OpenNest.Forms;
+
+namespace OpenNest.WinForms.Tests.Forms;
+
+///
+/// Machine clamps in the desktop: the Clamps tab of Machine Configuration edits a machine's rail,
+/// and Nest > Clamps saves its machine defaults to the nest's machine.
+///
+public class MachineClampsFormsTests
+{
+ [Fact]
+ public void ClampsTab_EditsTheMachineRail()
+ {
+ RunSta(() =>
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(directory);
+ try
+ {
+ var provider = new LocalJsonProvider(directory);
+ var machine = new MachineConfig { Name = "Laser" };
+ provider.SaveMachine(machine);
+
+ using var form = new MachineConfigForm(provider);
+ var node = Find(form, "_tree").Nodes.Cast().Single(n => n.Tag is MachineConfig);
+ // A hidden tree raises no AfterSelect, so show the machine as selecting it would.
+ typeof(MachineConfigForm)
+ .GetMethod("Tree_AfterSelect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
+ .Invoke(form, new object[] { null, new TreeViewEventArgs(node) });
+ var shown = (MachineConfig)node.Tag;
+ Assert.Null(shown.Clamps);
+
+ var tab = All(form).Single().TabPages.Cast().Single(p => p.Text == "Clamps");
+ All(tab).Single(c => c.Text == "Give new nests these clamps").Checked = true;
+ var add = All