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