feat(machines): keep the clamp rail on each machine

Clamps are physical fixtures that differ between machines, but one global
clamps.json served every machine, so a plasma table and a fiber laser could
not have different rails.

- MachineConfig.Clamps holds each machine's rail in the machine's units,
  edited on a new Clamps tab in Tools > Machine Configuration.
- New and BOM import give a nest the rail of the machine they assign;
  Nest > Clamps loads and saves the nest's machine's clamps. Each nest keeps
  its own copy, so machine edits never move existing nests' clamps.
- At startup, while no machine has clamps, the legacy clamps.json (or the
  server's shared copy) is copied once into the default or only machine;
  with several machines and no default the user picks one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-27 09:32:56 -04:00
1 parent 3e767ca3b6
commit 3e1df45c7f
19 files changed
+770 -99

No files matched your search

+5 -3
View File
@@ -23,9 +23,11 @@ public enum ClampSettingsStatus
}
/// <summary>
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> 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
/// <see cref="MachineConfig.GetClampSettings"/>) 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 (<see cref="LegacyClampMigration"/>). Off by default:
/// until <see cref="Enabled"/> is set with at least one center, new nests get no clamps.
/// Loading never throws; values are validated field by field.
/// </summary>
public sealed class ClampSettings
+32
View File
@@ -0,0 +1,32 @@
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public static class LegacyClampMigration
{
/// <summary>
/// Whether <paramref name="legacy"/> (null when there is no legacy file) still has to be
/// copied: it lists at least one clamp and none of <paramref name="machines"/> has clamps yet.
/// </summary>
public static bool IsNeeded(ClampSettings? legacy, IReadOnlyCollection<MachineConfig> machines) =>
legacy is { ClampCount: > 0 } && machines.Count > 0 && machines.All(m => m.Clamps == null);
/// <summary>
/// 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.
/// </summary>
public static MachineConfig? Target(IReadOnlyCollection<MachineConfig> machines, Guid? defaultMachineId) =>
machines.FirstOrDefault(m => m.Id == defaultMachineId)
?? (machines.Count == 1 ? machines.First() : null);
/// <summary>Gives <paramref name="machine"/> the legacy rail, converted to the machine's units.</summary>
public static void Apply(ClampSettings legacy, MachineConfig machine)
{
ArgumentNullException.ThrowIfNull(legacy);
ArgumentNullException.ThrowIfNull(machine);
machine.Clamps = MachineClamps.FromSettings(legacy, machine.Units);
}
}
+85
View File
@@ -0,0 +1,85 @@
namespace OpenNest.Data;
/// <summary>
/// A machine's fixed edge-clamp rail, stored on its <see cref="MachineConfig"/> in the machine's
/// units. New nests assigned the machine copy it as their own rail; editing it never changes an
/// existing nest. <see cref="ToSettings"/> validates the stored values field by field.
/// </summary>
public sealed class MachineClamps
{
/// <summary>Whether new nests assigned this machine receive the rail. Default off.</summary>
public bool Enabled { get; set; }
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; set; } = ClampSettings.DefaultWidth;
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; set; } = ClampSettings.DefaultDepth;
/// <summary>Minutes to remove one clamp and put it back after cutting; never scaled.</summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; set; } = new();
/// <summary>Far-side clamp centers along X, measured from the plate origin.</summary>
public List<double> FarSideCenters { get; set; } = new();
/// <summary>Origin-end clamp centers along Y, measured from the plate origin.</summary>
public List<double> OriginEndCenters { get; set; } = new();
/// <summary>Far-end clamp centers along Y, measured from the plate origin.</summary>
public List<double> FarEndCenters { get; set; } = new();
/// <summary>The <see cref="Units"/> matching a machine's <see cref="UnitSystem"/>.</summary>
public static Units UnitsOf(UnitSystem units) =>
units == UnitSystem.Millimeters ? Units.Millimeters : Units.Inches;
/// <summary>
/// <paramref name="settings"/> converted to a machine in <paramref name="units"/>, e.g. for
/// "Save as Machine Defaults" from a nest in other units or the clamps.json migration.
/// </summary>
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(),
};
}
/// <summary>
/// The rail as clamp settings in the machine's <paramref name="units"/>. An invalid size or
/// removal time falls back to the default, and an invalid center is dropped, as when
/// <see cref="ClampSettings.Parse"/> reads a file.
/// </summary>
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<double>? centers) =>
(centers ?? Enumerable.Empty<double>()).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);
}
+10
View File
@@ -20,8 +20,18 @@ public class MachineConfig
/// </summary>
public SupportGridSettings? SupportGrid { get; set; }
/// <summary>
/// The edge-clamp rail new nests assigned this machine receive, in <see cref="Units"/>; null
/// until set (the machine then gives nests no clamps).
/// </summary>
public MachineClamps? Clamps { get; set; }
public MotionConfig GetMotionOrPlaceholder() => Motion ?? MotionConfig.Placeholder(Units);
/// <summary>The machine's clamps in its units; off, at the default clamp size, when it has none.</summary>
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);
+4 -1
View File
@@ -40,7 +40,10 @@ public static class SharedSettingKinds
/// <summary>A post processor's settings, keyed by the post's type name.</summary>
public const string Post = "post";
/// <summary>The <see cref="ClampSettings"/> new nests start from, under <see cref="DefaultKey"/>.</summary>
/// <summary>
/// The legacy <see cref="ClampSettings"/> new nests started from before each machine held its
/// clamps, under <see cref="DefaultKey"/>; read only to copy them onto a machine once.
/// </summary>
public const string Clamps = "clamps";
/// <summary>The <see cref="NestDefaults"/> new nests start from, under <see cref="DefaultKey"/>.</summary>
+230
View File
@@ -0,0 +1,230 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
/// <summary>
/// Each machine's own clamp rail (<see cref="MachineConfig.Clamps"/>, 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.
/// </summary>
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<double> { 6, 21, 36 },
FarEndCenters = new List<double> { 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<double> { 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<double> { 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<double> { 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<MachineConfig>()));
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)));
}
}
@@ -0,0 +1,110 @@
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>
/// Machine clamps in the desktop: the Clamps tab of Machine Configuration edits a machine's rail,
/// and Nest &gt; Clamps saves its machine defaults to the nest's machine.
/// </summary>
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<TreeView>(form, "_tree").Nodes.Cast<TreeNode>().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<TabControl>(form).Single().TabPages.Cast<TabPage>().Single(p => p.Text == "Clamps");
All<CheckBox>(tab).Single(c => c.Text == "Give new nests these clamps").Checked = true;
var add = All<Button>(tab).Single(b => b.Text == "Add");
All<System.Windows.Forms.NumericUpDown>(add.Parent).Single().Value = 24;
Click(add);
Assert.NotNull(shown.Clamps);
Assert.True(shown.Clamps!.Enabled);
Assert.Equal(new double[] { 24 }, shown.Clamps.Centers);
Assert.Equal(ClampSettings.DefaultWidth, shown.Clamps.Width);
Assert.NotNull(shown.GetClampSettings().RailFor(Units.Inches));
}
finally
{
Directory.Delete(directory, recursive: true);
}
});
}
[Fact]
public void ClampsDialog_WithoutAMachine_CannotSaveMachineDefaults()
{
RunSta(() =>
{
using var form = new ClampsForm(null, Units.Inches, ClampSettings.Fallback, null, null);
Assert.False(All<Button>(form).Single(b => b.Text == "Save as Machine Defaults").Enabled);
Assert.Contains(All<GroupBox>(form), g => g.Text == "Machine defaults (no machine assigned)");
});
}
[Fact]
public void ClampsDialog_SavesToTheNestsMachine_AndLoadsWhatItSaved()
{
RunSta(() =>
{
var rail = new ClampRail(1.5, 0.625, new[] { new RailClamp(ClampEdge.OriginSide, 6) });
ClampSettings saved = null;
using var form = new ClampsForm(rail, Units.Inches, ClampSettings.Fallback, "Fiber", s => saved = s);
Assert.Contains(All<GroupBox>(form), g => g.Text == "Machine defaults (Fiber)");
All<CheckBox>(form).Single(c => c.Text == "Give new nests these clamps").Checked = true;
Click(All<Button>(form).Single(b => b.Text == "Save as Machine Defaults"));
Assert.NotNull(saved);
Assert.True(saved.Enabled);
Assert.Equal(new double[] { 6 }, saved.Centers);
form.Editor.RemoveCenterAt(0);
Click(All<Button>(form).Single(b => b.Text == "Load Machine Defaults"));
Assert.Equal(6, Assert.Single(form.Editor.Clamps).Center);
});
}
// PerformClick does nothing on a form that is not shown.
private static void Click(Button button) =>
typeof(Control).GetMethod("OnClick", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(button, new object[] { EventArgs.Empty });
private static T Find<T>(Control root, string name) where T : Control =>
Assert.IsType<T>(root.Controls.Find(name, true).Single());
private static IEnumerable<T> All<T>(Control root) where T : Control
{
foreach (Control child in root.Controls)
{
if (child is T match)
yield return match;
foreach (var nested in All<T>(child))
yield return nested;
}
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, Timeout.InfiniteTimeSpan, "The STA test did not complete.");
}
+2 -2
View File
@@ -84,9 +84,9 @@ namespace OpenNest.Actions
private Units Units => Nest?.Units ?? Units.Inches;
// A nest without clamps takes its first clamp's size from the machine defaults.
// A nest without clamps takes its first clamp's size from its machine's clamps.
private ClampSettings Defaults =>
defaults ??= MachineSettings.LoadSavedClampSettings(out _).ConvertedTo(Units);
defaults ??= MachineSettings.LoadClampSettings(MachineSettings.FindMachine(Nest?.Machine)).ConvertedTo(Units);
private double ClampWidth => Rail?.Width ?? Defaults.Width;
+2 -2
View File
@@ -408,9 +408,9 @@ namespace OpenNest.Forms
var jobName = txtJobName.Text.Trim();
var nestsCreated = 0;
var importErrors = new List<string>();
// Read when the nests are created, warning (once per session) like New does.
var clamps = MachineSettings.LoadClampSettings(this);
// Read when the nests are created, like New does.
var machine = MachineSettings.DefaultMachine(_defaults, MachineSettings.LoadMachines());
var clamps = MachineSettings.LoadClampSettings(machine);
foreach (var group in groups)
{
+20 -10
View File
@@ -10,13 +10,13 @@ namespace OpenNest.Forms;
/// <summary>
/// Nest &gt; Clamps...: edits the current nest's machine clamp rail (identical clamps, each on
/// one sheet edge with its center measured from the plate origin along it). OK applies the rail
/// to every plate of this nest; Cancel discards those edits. Save as Machine Defaults writes
/// clamps.json immediately and is not undone by Cancel. All decisions live in
/// to every plate of this nest; Cancel discards those edits. Save as Machine Defaults writes the
/// nest's machine configuration immediately and is not undone by Cancel. All decisions live in
/// <see cref="ClampRailEditor"/>; this form only renders and forwards input.
/// </summary>
public sealed class ClampsForm : Form
{
private readonly ClampSettings machineDefaults;
private ClampSettings machineDefaults;
private readonly Action<ClampSettings> saveDefaults;
private readonly NumericUpDown widthBox;
private readonly NumericUpDown depthBox;
@@ -30,11 +30,17 @@ public sealed class ClampsForm : Form
private readonly CheckBox applyToNewNestsBox;
private readonly Label savedLabel;
/// <param name="saveDefaults">Saves the machine defaults; this PC's clamp settings file when omitted.</param>
public ClampsForm(ClampRail rail, Units units, ClampSettings machineDefaults, Action<ClampSettings> saveDefaults = null)
/// <param name="machineDefaults">The clamps of the nest's machine, in any units.</param>
/// <param name="machineName">The nest's machine, or null when none is assigned.</param>
/// <param name="saveDefaults">
/// Saves the clamps as the nest's machine's; null when the nest has no machine on file, which
/// disables saving.
/// </param>
public ClampsForm(ClampRail rail, Units units, ClampSettings machineDefaults, string machineName,
Action<ClampSettings> saveDefaults)
{
this.machineDefaults = machineDefaults ?? ClampSettings.Fallback;
this.saveDefaults = saveDefaults ?? (settings => settings.Save(ClampSettings.DefaultPath));
this.saveDefaults = saveDefaults;
Editor = new ClampRailEditor(rail, units, this.machineDefaults.Enabled);
var unit = UnitsHelper.GetShortString(units);
@@ -113,7 +119,7 @@ public sealed class ClampsForm : Form
var defaultsGroup = new GroupBox
{
Text = "Machine defaults (clamps.json)",
Text = string.IsNullOrEmpty(machineName) ? "Machine defaults (no machine assigned)" : $"Machine defaults ({machineName})",
Dock = DockStyle.Top,
AutoSize = true,
Padding = new Padding(8),
@@ -140,10 +146,13 @@ public sealed class ClampsForm : Form
{
AutoSize = true,
MaximumSize = new Size(430, 0),
Text =
"Save as Machine Defaults writes the file immediately; Cancel below does not undo "
+ "it. OK and Cancel only affect this nest.",
Text = saveDefaults == null
? "Assign this nest a machine on file in Nest > Machine to save its clamps."
: "Save as Machine Defaults writes the machine configuration immediately; Cancel below "
+ "does not undo it. OK and Cancel only affect this nest.",
};
applyToNewNestsBox.Enabled = saveDefaults != null;
saveDefaultsButton.Enabled = saveDefaults != null;
defaultsLayout.Controls.Add(applyToNewNestsBox);
defaultsLayout.Controls.Add(defaultsButtons);
defaultsLayout.Controls.Add(savedLabel);
@@ -270,6 +279,7 @@ public sealed class ClampsForm : Form
{
var settings = Editor.ToMachineDefaults();
saveDefaults(settings);
machineDefaults = settings;
savedLabel.Text = settings.Enabled
? $"Saved {DateTime.Now:t}: new nests get these {settings.ClampCount} clamps. Cancel does not undo this."
: $"Saved {DateTime.Now:t}: new nests get no clamps. Cancel does not undo this.";
+134 -4
View File
@@ -250,8 +250,8 @@ namespace OpenNest.Forms
return;
var confirm = MessageBox.Show(
"Changing units does not convert existing thickness, kerf, lead and "
+ "motion values — they will keep their current numbers. Continue?",
"Changing units does not convert existing thickness, kerf, lead, "
+ "motion and clamp values — they will keep their current numbers. Continue?",
"Change Units",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning
@@ -267,9 +267,139 @@ namespace OpenNest.Forms
AddField(layout, ref row, "Units:", unitsCombo);
group.Controls.Add(layout);
var general = new Panel { Dock = DockStyle.Fill, AutoScroll = true };
// Docked to the top, so the last added sits first.
_detailPanel.Controls.Add(BuildMotionGroup(machine));
_detailPanel.Controls.Add(group);
general.Controls.Add(BuildMotionGroup(machine));
general.Controls.Add(group);
var tabs = new TabControl { Dock = DockStyle.Fill };
tabs.TabPages.Add(CreateTab("General", general));
tabs.TabPages.Add(CreateTab("Clamps", BuildClampsTab(machine)));
var host = new Panel { Dock = DockStyle.Fill, Height = 420 };
host.Controls.Add(tabs);
_detailPanel.Controls.Add(host);
}
// The machine's edge-clamp rail, which new nests assigned the machine copy. The first edit
// stores the shown values (the default clamp size, off, when it has none) with the change.
private Control BuildClampsTab(MachineConfig machine)
{
var shown = machine.GetClampSettings();
var editor = new ClampRailEditor(null, shown.Units, shown.Enabled);
editor.LoadMachineDefaults(shown);
MachineClamps Clamps() => machine.Clamps ??= new MachineClamps
{
Enabled = shown.Enabled,
Width = shown.Width,
Depth = shown.Depth,
RemovalMinutes = shown.RemovalMinutes,
};
void Set(Action<MachineClamps> apply)
{
var clamps = Clamps();
apply(clamps);
var edges = new ClampSettings();
edges.SetClamps(editor.Clamps);
clamps.Centers = edges.Centers.ToList();
clamps.FarSideCenters = edges.FarSideCenters.ToList();
clamps.OriginEndCenters = edges.OriginEndCenters.ToList();
clamps.FarEndCenters = edges.FarEndCenters.ToList();
MarkDirty();
}
var layout = CreateDetailLayout();
var row = 0;
var enabledBox = new CheckBox
{
Text = "Give new nests these clamps",
AutoSize = true,
Checked = shown.Enabled,
};
enabledBox.CheckedChanged += (s, e) => Set(c => c.Enabled = enabledBox.Checked);
AddField(layout, ref row, "New nests:", enabledBox);
AddField(layout, ref row, "Clamp width:",
CreateLabeledNumeric(shown.Width, v => Set(c => c.Width = v), 4, LengthUnitSuffix, 1000, 0.125m));
AddField(layout, ref row, "Clamp depth:",
CreateLabeledNumeric(shown.Depth, v => Set(c => c.Depth = v), 4, LengthUnitSuffix, 1000, 0.125m));
AddField(layout, ref row, "Removal time:",
CreateLabeledNumeric(shown.RemovalMinutes, v => Set(c => c.RemovalMinutes = v), 2, _ => "min per clamp", 1000, 0.5m));
var centersGroup = new GroupBox
{
Text = $"Clamp centers, measured from the plate origin ({LengthUnitSuffix(null)})",
Dock = DockStyle.Fill,
Padding = new Padding(8),
};
var centersList = new ListBox { Dock = DockStyle.Fill, IntegralHeight = false };
var entry = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, WrapContents = false };
var edgeBox = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 120 };
foreach (var edge in ClampEdgeText.All)
edgeBox.Items.Add(ClampEdgeText.Name(edge));
edgeBox.SelectedIndex = 0;
var centerBox = CreateNumericBox(0, _ => { }, 4, 100000, 0.125m);
var addButton = new Button { Text = "Add", AutoSize = true };
var removeButton = new Button { Text = "Remove", AutoSize = true, Enabled = false };
entry.Controls.Add(edgeBox);
entry.Controls.Add(new Label { Text = "Center:", AutoSize = true, Padding = new Padding(0, 6, 0, 0) });
entry.Controls.Add(centerBox);
entry.Controls.Add(addButton);
entry.Controls.Add(removeButton);
centersGroup.Controls.Add(centersList);
centersGroup.Controls.Add(entry);
void RefreshCenters()
{
centersList.BeginUpdate();
centersList.Items.Clear();
foreach (var clamp in editor.Clamps)
centersList.Items.Add(
$"{ClampEdgeText.Name(clamp.Edge)}: {clamp.Center.ToString("0.####", System.Globalization.CultureInfo.CurrentCulture)}");
centersList.EndUpdate();
removeButton.Enabled = centersList.SelectedIndex >= 0;
}
addButton.Click += (s, e) =>
{
var clamp = new RailClamp(ClampEdgeText.All[edgeBox.SelectedIndex], (double)centerBox.Value);
if (!editor.TryAddClamp(clamp, out var error))
{
MessageBox.Show(this, error, Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
Set(_ => { });
RefreshCenters();
};
removeButton.Click += (s, e) =>
{
var index = centersList.SelectedIndex;
editor.RemoveCenterAt(index);
Set(_ => { });
RefreshCenters();
if (centersList.Items.Count > 0)
centersList.SelectedIndex = System.Math.Min(index, centersList.Items.Count - 1);
};
centersList.SelectedIndexChanged += (s, e) => removeButton.Enabled = centersList.SelectedIndex >= 0;
RefreshCenters();
var note = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
MaximumSize = new Size(560, 0),
ForeColor = SystemColors.GrayText,
Padding = new Padding(4, 0, 0, 8),
Text = "New nests assigned this machine copy these clamps. Changing them here never "
+ "moves the clamps of existing nests; use Load Machine Defaults in Nest > Clamps for that.",
};
var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) };
panel.Controls.Add(centersGroup);
panel.Controls.Add(note);
panel.Controls.Add(layout);
return panel;
}
private GroupBox BuildMotionGroup(MachineConfig machine)
+88 -50
View File
@@ -11,15 +11,13 @@ using OpenNest.IO;
namespace OpenNest.Forms
{
/// <summary>
/// The desktop entry points for the persisted machines, machine clamp settings and nest
/// The desktop entry points for the persisted machines (with their clamps) and nest
/// defaults. In Database mode each comes from the nest server once it holds a shared copy
/// (<see cref="SharedSettings"/>), otherwise from this PC's files. The warning variants
/// report unusable settings once per session.
/// </summary>
internal static class MachineSettings
{
private static bool clampSettingsWarned;
/// <summary>
/// The machine configurations edited in Tools > Machine Configuration: the server's when
/// it holds any, otherwise this PC's. <paramref name="refresh"/> re-reads the server first.
@@ -66,37 +64,100 @@ namespace OpenNest.Forms
/// <summary>Every material and grade on file across the readable machine configurations.</summary>
public static IEnumerable<MaterialConfig> LoadMaterials() =>
LoadMachines().SelectMany(machine => machine.Materials);
private static bool defaultsWarned;
private static bool clampMigrationAsked;
/// <summary>
/// Loads the machine clamp settings for creating new nests (New, BOM import); an
/// unreadable or partly invalid file warns once per session, because the new nest's
/// clamps may not match the machine.
/// The clamps new nests assigned <paramref name="machine"/> receive (New, BOM import, Load
/// Machine Defaults); off when there is no machine or it has no clamps.
/// </summary>
public static ClampSettings LoadClampSettings(IWin32Window owner)
{
var settings = LoadSavedClampSettings(out var status);
public static ClampSettings LoadClampSettings(MachineConfig machine) =>
machine?.GetClampSettings() ?? ClampSettings.Fallback;
if (
status is ClampSettingsStatus.Invalid or ClampSettingsStatus.Partial
&& !clampSettingsWarned
)
/// <summary>Saves <paramref name="settings"/> as the clamps of the machine with <paramref name="machineId"/>.</summary>
public static void SaveClampSettings(Guid machineId, ClampSettings settings)
{
var provider = CreateMachineProvider();
var machine = provider.GetMachine(machineId)
?? throw new IOException("The machine configuration is no longer on file.");
machine.Clamps = MachineClamps.FromSettings(settings, machine.Units);
provider.SaveMachine(machine);
}
/// <summary>
/// Copies the legacy clamps.json (or the server's shared copy) onto a machine once, while
/// no machine has clamps of its own: the nest defaults' machine, or the only machine. With
/// several machines and no default the user picks one, asked once per session.
/// </summary>
public static void MigrateLegacyClamps(IWin32Window owner)
{
try
{
clampSettingsWarned = true;
var problem =
status == ClampSettingsStatus.Invalid
? "could not be read, so new nests have no clamps"
: "have invalid values that were ignored, so new nests may be missing clamps";
var source = SharedClamps() != null ? "on the nest server" : ClampSettings.DefaultPath;
MessageBox.Show(
owner,
$"The clamp settings {problem}:\n{source}\n\nCheck the clamps in Nest > Clamps and use Save as Machine Defaults to rewrite them.",
"Clamps",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
var legacy = LoadLegacyClampSettings();
var provider = CreateMachineProvider();
var machines = ReadMachines(() => provider)
.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
if (!LegacyClampMigration.IsNeeded(legacy, machines))
return;
var target = LegacyClampMigration.Target(machines, LoadSavedNestDefaults(out _).MachineId);
if (target == null)
{
if (clampMigrationAsked)
return;
clampMigrationAsked = true;
target = AskClampMachine(owner, machines);
if (target == null)
return;
}
LegacyClampMigration.Apply(legacy, target);
provider.SaveMachine(target);
}
return settings;
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
MessageBox.Show(owner,
$"The clamp settings could not be copied to a machine:\n\n{ex.Message}\n\n"
+ "New nests get no clamps until a machine has them (Tools > Machine Configuration).",
"Clamps", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// The pre-machine clamp settings: the server's shared copy once it holds one, otherwise
// this PC's clamps.json; null when neither exists.
private static ClampSettings LoadLegacyClampSettings()
{
if (SharedSettings.Find(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) is { } shared)
return ClampSettings.Parse(shared.Json, out _);
return File.Exists(ClampSettings.DefaultPath) ? ClampSettings.Load(ClampSettings.DefaultPath) : null;
}
private static MachineConfig AskClampMachine(IWin32Window owner, IReadOnlyList<MachineConfig> machines)
{
var page = new TaskDialogPage
{
Caption = "Clamps",
Heading = "Which machine are these clamps on?",
Text = "Clamps are now kept with each machine. Choose the machine that gets the clamps "
+ "saved before, or choose later in Tools > Machine Configuration. Until then new "
+ "nests get no clamps.",
Icon = TaskDialogIcon.Information,
AllowCancel = true,
};
foreach (var machine in machines)
page.RadioButtons.Add(new TaskDialogRadioButton(machine.Name) { Tag = machine });
page.RadioButtons[0].Checked = true;
var copy = new TaskDialogButton("Copy Clamps");
page.Buttons.Add(copy);
page.Buttons.Add(new TaskDialogButton("Not Now"));
var handle = owner?.Handle ?? IntPtr.Zero;
var result = handle == IntPtr.Zero ? TaskDialog.ShowDialog(page) : TaskDialog.ShowDialog(handle, page);
return result == copy
? page.RadioButtons.FirstOrDefault(b => b.Checked)?.Tag as MachineConfig
: null;
}
/// <summary>
@@ -189,29 +250,6 @@ namespace OpenNest.Forms
provider.SaveMachine(machine);
}
/// <summary>The single desktop entry point for the persisted machine clamp settings.</summary>
public static ClampSettings LoadSavedClampSettings(out ClampSettingsStatus status) =>
SharedClamps() is { } shared
? ClampSettings.Parse(shared.Json, out status)
: ClampSettings.Load(ClampSettings.DefaultPath, out status);
/// <summary>
/// Saves the clamp settings new nests start from where they are read: the server once it
/// holds them, otherwise this PC's file. Throws <see cref="IOException"/> when the server
/// cannot take them.
/// </summary>
public static void SaveClampSettings(ClampSettings settings)
{
if (SharedSettings.Current() is { } current
&& current.Get(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) != null)
current.Save(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey, settings.ToJson());
else
settings.Save(ClampSettings.DefaultPath);
}
private static SharedSettingRecord SharedClamps() =>
SharedSettings.Find(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey);
/// <summary>
/// Loads the persisted nest defaults (see <see cref="LoadSavedNestDefaults"/>);
/// a corrupt or unreadable file warns once per session.
+6 -2
View File
@@ -135,8 +135,12 @@ namespace OpenNest.Forms
return;
}
var defaults = MachineSettings.LoadSavedClampSettings(out _);
using var form = new ClampsForm(nest.PlateDefaults.ClampRail, nest.Units, defaults, MachineSettings.SaveClampSettings);
var machine = MachineSettings.FindMachine(nest.Machine);
Action<ClampSettings> save = machine == null
? null
: settings => MachineSettings.SaveClampSettings(machine.Id, settings);
using var form = new ClampsForm(nest.PlateDefaults.ClampRail, nest.Units, MachineSettings.LoadClampSettings(machine),
machine?.Name ?? nest.Machine?.Name, save);
if (form.ShowDialog(this) != DialogResult.OK)
return;
+7 -2
View File
@@ -213,8 +213,13 @@ namespace OpenNest.Forms
mnuNestPost.Visible = mnuNestPost.DropDownItems.Count > 0;
postButton.Visible = postButton.DropDownItems.Count > 0;
// Database mode: offer this PC's settings to a server that has none yet.
Shown += (_, _) => SharedSettings.OfferUpload(this, posts.PostProcessors);
// Database mode: offer this PC's settings to a server that has none yet; then move
// the clamps saved before machines held them onto a machine, once.
Shown += (_, _) =>
{
SharedSettings.OfferUpload(this, posts.PostProcessors);
MachineSettings.MigrateLegacyClamps(this);
};
if (posts.Failures.Count > 0)
Shown += (_, _) => MessageBox.Show(this,
+3 -2
View File
@@ -40,8 +40,9 @@ namespace OpenNest.Forms
var defaults = MachineSettings.LoadNestDefaults();
(units is { } converted ? defaults.ConvertedTo(converted) : defaults).ApplyTo(nest);
// After the defaults so the rail and grid are converted to the new nest's units.
MachineSettings.LoadClampSettings(owner).ApplyToNewNest(nest);
MachineSettings.AssignMachine(nest, MachineSettings.DefaultMachine(defaults, MachineSettings.LoadMachines()));
var machine = MachineSettings.DefaultMachine(defaults, MachineSettings.LoadMachines());
MachineSettings.LoadClampSettings(machine).ApplyToNewNest(nest);
MachineSettings.AssignMachine(nest, machine);
return nest;
}
+1 -5
View File
@@ -177,7 +177,7 @@ namespace OpenNest.Forms
}
/// <summary>
/// When the server holds none of this PC's machines, post settings, clamp settings or nest
/// When the server holds none of this PC's machines (with their clamps), post settings or nest
/// defaults, offers to upload them so every workstation uses them. Documents the server
/// already holds are never replaced.
/// </summary>
@@ -200,10 +200,6 @@ namespace OpenNest.Forms
if (current.Get(SharedSettingKinds.Post, PostKey(post)) == null)
uploads.Add((SharedSettingKinds.Post, PostKey(post), post.SerializeConfig(), $"Post settings: {post.Name}"));
}
if (current.Get(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) == null
&& File.Exists(ClampSettings.DefaultPath))
uploads.Add((SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey,
ClampSettings.Load(ClampSettings.DefaultPath).ToJson(), "Clamp settings"));
if (current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null
&& File.Exists(NestDefaults.DefaultPath))
uploads.Add((SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey,
+23 -10
View File
@@ -66,18 +66,31 @@ the origin: the origin side and far side run along X, the origin end and far
end along Y. **Nest > Clamps...** edits the current nest's rail as a list, and
**Plate > Edit Clamps** edits it on the plate view (below). **OK** applies it to every plate of the nest (**Cancel** discards
the edits); removing every center removes the clamps. **Load Machine Defaults**
only fills the dialog. **Save as Machine Defaults** writes
`%APPDATA%\OpenNest\clamps.json` (or, in Database mode once the server holds them, the
[shared clamp settings](shared-settings.md)) immediately, with the clamps, their units and
whether new nests receive them; Cancel does not undo that write. The dialog
only fills the dialog with the clamps of the nest's machine (**Nest > Machine**).
**Save as Machine Defaults** writes the clamps, and whether new nests receive them,
into that machine's configuration immediately (in Database mode, the
[shared machine](shared-settings.md) once the server holds it); Cancel does not undo
that write. A nest without a machine on file cannot save its clamps. The dialog
refuses to open while nesting or a plate action is running.
Clamps are off by default. Until `clamps.json` is saved with clamps enabled and
at least one center, New and BOM import create nests without clamps exactly as
before. When enabled, a new nest gets the rail converted to its units. A missing
file keeps clamps off; an unreadable file, or one with ignored invalid values
(a bad clamp center is dropped), warns once per session that new nests may not
match the machine.
Clamps are physical fixtures of a machine, so each machine keeps its own rail on the
**Clamps** tab of **Tools > Machine Configuration**: whether new nests get it, the
clamp width and depth, the minutes to remove one clamp, and the clamp centers, all in
the machine's units. New and BOM import give a new nest the rail of the machine they
assign it (the **Tools > Nest Defaults** machine, or the first by name), converted to
the nest's units. A nest keeps its own copy: editing a machine's clamps, or assigning a
nest another machine, never moves the clamps of an existing nest; use **Load Machine
Defaults** for that. Clamps are off by default: a machine without clamps, or with them
switched off or without a center, gives new nests none. A stored size or removal time
that is not valid falls back to the default and an invalid center is dropped.
Before machines held clamps, one `%APPDATA%\OpenNest\clamps.json` (or the server's
shared clamp settings) served every machine. At startup, while no machine has clamps of
its own, OpenNest copies that rail once into the Nest Defaults machine, or the only
machine, converted to the machine's units. With several machines and no default it asks
which machine has those clamps (once per session; **Not Now** leaves new nests without
clamps until a machine has some). Once any machine has clamps the old file is no longer
read; it is left in place.
Every plate follows the nest's rail at its own size and quadrant: clamps wholly
beyond a shorter sheet are left off it, one still reaching onto the sheet keeps
+3 -2
View File
@@ -118,8 +118,9 @@ utilization, cut sequence or CNC output. Files without the key load with no
zones, and an individually malformed entry (blank id, non-positive size) is
dropped while the rest of the nest loads.
`plateDefaults.clampRail` optionally stores the nest's machine clamp rail:
`width` (along the edge), `depth` (into the sheet) and `centers` (X positions
`plateDefaults.clampRail` optionally stores the nest's machine clamp rail, the nest's
own copy (a new nest copies it from its `machine`'s clamps; later machine edits do not
change it): `width` (along the edge), `depth` (into the sheet) and `centers` (X positions
measured from the plate origin along the origin-side edge). Clamps on the other
edges are listed in optional `farSideCenters` (X, along the edge across the
sheet's width from the origin), `originEndCenters` (Y, along the edge through
+5 -4
View File
@@ -6,9 +6,9 @@ building nests and posts the same way:
| Kind | Key | What it holds | This PC's own copy |
|---|---|---|---|
| `machine` | machine id | A Tools > Machine Configuration entry: materials, grades, thicknesses, leads, kerf, cut-offs, motion limits, plate sizes, support grid | `%APPDATA%\OpenNest\Machines\<id>.json` |
| `machine` | machine id | A Tools > Machine Configuration entry: materials, grades, thicknesses, leads, kerf, cut-offs, motion limits, plate sizes, support grid, clamps | `%APPDATA%\OpenNest\Machines\<id>.json` |
| `post` | post type name, e.g. `OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor` | A post's settings, as its JSON file holds them | `<post>.json` beside the post DLL in `Posts\` |
| `clamps` | `default` | Machine clamp defaults (Save as Machine Defaults) | `%APPDATA%\OpenNest\clamps.json` |
| `clamps` | `default` | Legacy clamp settings, read only to copy them onto a machine once (machines now hold their clamps) | `%APPDATA%\OpenNest\clamps.json` |
| `nest-defaults` | `default` | Tools > Nest Defaults: units, sheet size, quadrant, spacing, default machine | `%APPDATA%\OpenNest\defaults.json` |
File mode, the console, MCP and API front ends use only this PC's copies.
@@ -21,7 +21,7 @@ one), posts per post, clamps and nest defaults as single documents. Saves go whe
settings were read, so editing a kind the server does not hold yet changes only this PC.
When the desktop opens in Database mode and the server lacks any of this PC's machines (all
of them, when it holds none), post settings, clamp settings or nest defaults, it lists them
of them, when it holds none), post settings or nest defaults, it lists them
and offers to upload them. The first workstation to answer Yes seeds the server; a document
the server already holds is never replaced, so later workstations only add what is missing
(a post installed on just one PC, for example). Declining keeps that PC on its own copies and
@@ -45,7 +45,8 @@ saved first the server refuses it and the desktop takes their copy:
even though the Cincinnati post records the material library the nest selected.
- Machine Configuration: the unsaved machines stay pending; reopen the window to see the
other changes.
- Clamps, nest defaults, support grid: the save reports the conflict.
- Nest > Clamps and Nest > Support Grid machine defaults, nest defaults: the save reports
the conflict.
The window title of Tools > Machine Configuration says whether it edits the shared machines,
this PC's (not shared yet), or a read-only copy. Tools > Nest Defaults says the same beside