mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 05:51:34 -05:00
A clamp rail can now carry its own Clearance, the gap parts keep from every clamp footprint, independent of part spacing. Unset, it keeps the sheet's bottom edge spacing as before. Clamp blocks drawn under the sheet can use close to zero so the laser only stays off them. Plate.ClampClearance and NestPlateStock.ClampClearance resolve it once; the engines, interactive fill/pack/push, the layout check, Expander walls and automatic cut-off placement all read it. The interactive push now holds clamps at their square clearance keep-out (the layout check's shape) instead of the part spacing, so parts no longer tuck into its corners; the clamped fill characterization digests are re-pinned after checking the new layouts validate. Persisted as an optional `clearance` on the .nest clamp rail and the machine clamp settings; edited in Nest > Clamps and the machine's Clamps tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
15 KiB
C#
435 lines
15 KiB
C#
using System.Text.Json;
|
|
using OpenNest.Data;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Tests.Data;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<double>();
|
|
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));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("enabled")]
|
|
[InlineData("units")]
|
|
[InlineData("width")]
|
|
[InlineData("depth")]
|
|
[InlineData("centers")]
|
|
public void ExplicitNullIsAnIgnoredValueAndIsReported(string field)
|
|
{
|
|
File.WriteAllText(_path, $$"""{ "enabled": true, "centers": [6], "{{field}}": null }""");
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Partial, status);
|
|
Assert.Equal(1.5, loaded.Width);
|
|
Assert.Equal(0.625, loaded.Depth);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetricFileWithoutSizesGetsThePhysicalDefaultClamp()
|
|
{
|
|
File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "centers": [152.4] }""");
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Ok, status);
|
|
Assert.Equal(1.5 * 25.4, loaded.Width, 9);
|
|
Assert.Equal(0.625 * 25.4, loaded.Depth, 9);
|
|
Assert.Equal(1.5, loaded.RailFor(Units.Inches)!.Width, 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void MetricFileWithAnInvalidSizeFallsBackToThePhysicalDefault()
|
|
{
|
|
File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "width": -1, "depth": 15, "centers": [152.4] }""");
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Partial, status);
|
|
Assert.Equal(38.1, loaded.Width, 9);
|
|
Assert.Equal(15, loaded.Depth);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{ "enabled": true, "width": 1e308, "centers": [6] }""")]
|
|
[InlineData("""{ "enabled": true, "depth": 1e308, "centers": [6] }""")]
|
|
[InlineData("""{ "enabled": true, "centers": [6, 1e308] }""")]
|
|
public void ValuesThatCannotConvertBetweenUnitsAreIgnored(string json)
|
|
{
|
|
File.WriteAllText(_path, json);
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Partial, status);
|
|
var nest = new Nest { Units = Units.Millimeters };
|
|
loaded.ApplyToNewNest(nest); // must not throw while creating a nest
|
|
Assert.Equal(new[] { 6 * 25.4 }, nest.PlateDefaults.ClampRail!.Centers);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// D9: minutes to remove one clamp, stored alongside the rail's physical size.
|
|
|
|
[Fact]
|
|
public void RemovalMinutesDefaultsToThePlannerValue()
|
|
{
|
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, new ClampSettings().RemovalMinutes);
|
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, ClampSettings.Fallback.RemovalMinutes);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovalMinutesRoundTripsAndIsNeverScaledByUnits()
|
|
{
|
|
var settings = Machine();
|
|
settings.RemovalMinutes = 8;
|
|
settings.Save(_path);
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
Assert.Equal(ClampSettingsStatus.Ok, status);
|
|
Assert.Equal(8, loaded.RemovalMinutes);
|
|
using var json = JsonDocument.Parse(File.ReadAllText(_path));
|
|
Assert.Equal(8, json.RootElement.GetProperty("removalMinutes").GetDouble());
|
|
|
|
// Width/depth/centers scale between units; removal time (not a length) never does.
|
|
var metric = loaded.ConvertedTo(Units.Millimeters);
|
|
Assert.Equal(8, metric.RemovalMinutes);
|
|
Assert.Equal(8, loaded.RailFor(Units.Millimeters)!.RemovalMinutes);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingRemovalMinutesDefaultsOnLoad()
|
|
{
|
|
File.WriteAllText(_path, """{ "enabled": true, "centers": [6] }""");
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Ok, status);
|
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": -1 }""")]
|
|
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": "five" }""")]
|
|
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": null }""")]
|
|
public void InvalidRemovalMinutesFallsBackAndIsReported(string json)
|
|
{
|
|
File.WriteAllText(_path, json);
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Partial, status);
|
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroRemovalMinutesIsValid()
|
|
{
|
|
var settings = Machine();
|
|
settings.RemovalMinutes = 0;
|
|
settings.Save(_path);
|
|
|
|
var loaded = ClampSettings.Load(_path, out var status);
|
|
Assert.Equal(ClampSettingsStatus.Ok, status);
|
|
Assert.Equal(0, loaded.RemovalMinutes);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearanceRoundTripsConvertsAndReachesTheRail()
|
|
{
|
|
var settings = Machine();
|
|
settings.Clearance = 0.05;
|
|
|
|
var read = ClampSettings.Parse(settings.ToJson(), out var status);
|
|
|
|
Assert.Equal(ClampSettingsStatus.Ok, status);
|
|
Assert.Equal(0.05, read.Clearance);
|
|
Assert.Equal(0.05 * 25.4, read.ConvertedTo(Units.Millimeters).Clearance!.Value, 9);
|
|
Assert.Equal(0.05, read.RailFor(Units.Inches)!.Clearance);
|
|
Assert.Equal(0.05, ClampSettings.FromRail(read.RailFor(Units.Inches), Units.Inches, true).Clearance);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingClearanceKeepsTheBottomEdgeSpacingAndABadOneIsIgnored()
|
|
{
|
|
Assert.DoesNotContain("clearance", Machine().ToJson());
|
|
Assert.Null(ClampSettings.Parse(Machine().ToJson(), out _).RailFor(Units.Inches)!.Clearance);
|
|
|
|
var read = ClampSettings.Parse("{\"enabled\": true, \"centers\": [6], \"clearance\": -1}", out var status);
|
|
Assert.Equal(ClampSettingsStatus.Partial, status);
|
|
Assert.Null(read.Clearance);
|
|
}
|
|
}
|