diff --git a/AGENTS.md b/AGENTS.md
index a560aa0c..66a7eac2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -76,7 +76,7 @@ Read the relevant contract before changing its behavior:
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
-- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
+- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf, tabs, pierce and cut-off settings per material and thickness; the Cutting Parameters dialog saves to that thickness
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
- [Server nesting](docs/server-nesting.md): Database-mode Auto Nest queued to the server's worker container (`OPENNEST_ROLE=worker`), one job at a time with no time limit, streaming progress and the best plan so far over SSE; the desktop replays the recorded engine answers to validate and bind locally. My Server Jobs reopens results in later sessions, and each result is kept at most once (`If-Match` Keep). The HTTP server stays off Engine/IO
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
diff --git a/OpenNest.Data/CutOffConfig.cs b/OpenNest.Data/CutOffConfig.cs
index 2cc60608..be975b62 100644
--- a/OpenNest.Data/CutOffConfig.cs
+++ b/OpenNest.Data/CutOffConfig.cs
@@ -6,4 +6,8 @@ public class CutOffConfig
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public string Direction { get; set; } = "AwayFromOrigin";
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/EdgeSpacingConfig.cs b/OpenNest.Data/EdgeSpacingConfig.cs
index 956bdfcc..66dbd760 100644
--- a/OpenNest.Data/EdgeSpacingConfig.cs
+++ b/OpenNest.Data/EdgeSpacingConfig.cs
@@ -7,4 +7,8 @@ public class EdgeSpacingConfig
public double Bottom { get; set; }
public double Right { get; set; }
public double Top { get; set; }
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/LeadConfig.cs b/OpenNest.Data/LeadConfig.cs
index 6e024118..38735102 100644
--- a/OpenNest.Data/LeadConfig.cs
+++ b/OpenNest.Data/LeadConfig.cs
@@ -6,4 +6,8 @@ public class LeadConfig
public double Length { get; set; }
public double Angle { get; set; } = 90.0;
public double Radius { get; set; }
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/MachineClamps.cs b/OpenNest.Data/MachineClamps.cs
index 4695e72c..786509d9 100644
--- a/OpenNest.Data/MachineClamps.cs
+++ b/OpenNest.Data/MachineClamps.cs
@@ -89,4 +89,8 @@ public sealed class MachineClamps
// 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);
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/MachineConfig.cs b/OpenNest.Data/MachineConfig.cs
index 3859b984..e4892331 100644
--- a/OpenNest.Data/MachineConfig.cs
+++ b/OpenNest.Data/MachineConfig.cs
@@ -60,4 +60,8 @@ public class MachineConfig
///
public IEnumerable GetGrades(string name) =>
Materials.Where(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/MachineCuttingDefaults.cs b/OpenNest.Data/MachineCuttingDefaults.cs
index c3afd6da..58e611e6 100644
--- a/OpenNest.Data/MachineCuttingDefaults.cs
+++ b/OpenNest.Data/MachineCuttingDefaults.cs
@@ -46,8 +46,10 @@ public static class MachineCuttingDefaults
///
/// Replaces the lead-ins, lead-outs and kerf of with the
- /// machine's for the nest, and records the machine, material and thickness they came from.
- /// Returns false, changing nothing, when the machine has no settings for the nest.
+ /// machine's for the nest, and its lead-in angle rounding, tabs and pierce when the thickness
+ /// sets them (), and records the machine, material and
+ /// thickness they came from. Returns false, changing nothing, when the machine has no
+ /// settings for the nest.
///
public static bool ApplyTo(CuttingParameters parameters, MachineConfig? machine, Nest nest)
{
@@ -58,6 +60,8 @@ public static class MachineCuttingDefaults
var scale = Scale(machine!.Units, nest.Units);
ApplyLeads(parameters, settings, scale);
parameters.Kerf = settings.Kerf * scale;
+ if (settings.Cutting is { } cutting)
+ ApplyCutting(parameters, cutting, scale);
parameters.MachineName = machine.Name;
parameters.MaterialName = nest.Material.Name;
parameters.Grade = nest.Material.Grade;
@@ -65,6 +69,141 @@ public static class MachineCuttingDefaults
return true;
}
+ ///
+ /// Stores the leads, lead-in angle rounding, tabs and pierce of
+ /// (in the nest's units) on the machine's thickness for the nest, in the machine's units: the
+ /// inverse of . Kerf, cut-off,
+ /// spacing and plate sizes are kept. Inside cuts that match the outside ones keep following
+ /// them. Returns false, changing nothing, when the machine has no settings for the nest.
+ ///
+ public static bool WriteTo(MachineConfig? machine, Nest nest, CuttingParameters parameters)
+ {
+ ArgumentNullException.ThrowIfNull(parameters);
+ if (Find(machine, nest) is not { } settings)
+ return false;
+
+ var scale = Scale(nest.Units, machine!.Units);
+ // A lead the configuration cannot hold (Line + Line, which it never applies) leaves the
+ // lead as it was; so does a Clean Hole on outside cuts, which is for holes only.
+ var insideFrom = settings.InsideLeadIn ?? settings.LeadIn;
+ var outsideIn = parameters.ExternalLeadIn is LineLineLeadIn or CleanHoleLeadIn
+ ? settings.LeadIn
+ : ToConfig(Scaled(parameters.ExternalLeadIn, scale), settings.LeadIn);
+ var outsideOut = ToConfig(Scaled(parameters.ExternalLeadOut, scale), settings.LeadOut);
+ var insideIn = parameters.InternalLeadIn is LineLineLeadIn
+ ? insideFrom
+ : ToConfig(Scaled(parameters.InternalLeadIn, scale), insideFrom);
+ var insideOut = ToConfig(Scaled(parameters.InternalLeadOut, scale), settings.InsideLeadOut ?? settings.LeadOut);
+ settings.LeadIn = outsideIn;
+ settings.LeadOut = outsideOut;
+ var follows = SameLead(insideIn, settings.LeadIn) && SameLead(insideOut, settings.LeadOut);
+ settings.InsideLeadIn = follows ? null : insideIn;
+ settings.InsideLeadOut = follows ? null : insideOut;
+ settings.Cutting = ToCuttingConfig(parameters, scale, settings.Cutting);
+ return true;
+ }
+
+ ///
+ /// The thickness's lead-in angle rounding, tabs and pierce as cutting parameters in the
+ /// machine's units, for editing them; 's defaults when it
+ /// sets none.
+ ///
+ public static CuttingParameters CuttingOf(ThicknessCuttingConfig? cutting)
+ {
+ var parameters = new CuttingParameters();
+ ApplyCutting(parameters, cutting ?? new ThicknessCuttingConfig(), 1);
+ return parameters;
+ }
+
+ /// The thickness settings form of ' rounding, tabs and pierce, unscaled.
+ public static ThicknessCuttingConfig ToCuttingConfig(CuttingParameters parameters, ThicknessCuttingConfig? previous = null) =>
+ ToCuttingConfig(parameters, 1, previous);
+
+ private static ThicknessCuttingConfig ToCuttingConfig(CuttingParameters parameters, double scale, ThicknessCuttingConfig? previous) =>
+ new()
+ {
+ RoundLeadInAngles = parameters.RoundLeadInAngles,
+ LeadInAngleIncrement = parameters.LeadInAngleIncrement,
+ Tabs = parameters.SupportTabs,
+ TabBySize = parameters.TabBySize,
+ TabSize = (parameters.TabConfig?.Size ?? previous?.TabSize ?? new ThicknessCuttingConfig().TabSize) * scale,
+ TabHeldMaxLength = parameters.TabHeldMaxLength * scale,
+ TabSizeMinWidth = parameters.TabSizeMinWidth * scale,
+ TabSizeMaxWidth = parameters.TabSizeMaxWidth * scale,
+ PierceClearance = parameters.PierceClearance * scale,
+ PierceDiameter = parameters.PierceDiameter * scale,
+ Unknown = previous?.Unknown,
+ };
+
+ private static void ApplyCutting(CuttingParameters parameters, ThicknessCuttingConfig cutting, double scale)
+ {
+ parameters.RoundLeadInAngles = cutting.RoundLeadInAngles;
+ parameters.LeadInAngleIncrement = cutting.LeadInAngleIncrement;
+ parameters.SupportTabs = cutting.Tabs;
+ parameters.TabBySize = cutting.TabBySize;
+ // The thickness's tabs replace any retired "Tab every part" the fallback still carries.
+ parameters.TabsEnabled = false;
+ parameters.TabConfig = new NormalTab { Size = cutting.TabSize * scale };
+ parameters.TabHeldMaxLength = cutting.TabHeldMaxLength * scale;
+ parameters.TabSizeMinWidth = cutting.TabSizeMinWidth * scale;
+ parameters.TabSizeMaxWidth = cutting.TabSizeMaxWidth * scale;
+ parameters.PierceClearance = cutting.PierceClearance * scale;
+ parameters.PierceDiameter = cutting.PierceDiameter * scale;
+ }
+
+ // Equal for the fields the lead's type uses; ToConfig carries the others over from before.
+ private static bool SameLead(LeadConfig a, LeadConfig b)
+ {
+ if (a.Type != b.Type)
+ return false;
+ var (length, angle, radius) = a.Type switch
+ {
+ "Line" => (true, true, false),
+ "Arc" => (false, false, true),
+ "Line + Arc" => (true, true, true),
+ "Clean Hole" => (true, false, true),
+ "Line + Line" => (true, true, false),
+ _ => (false, false, false),
+ };
+ return (!length || a.Length.Equals(b.Length))
+ && (!angle || a.Angle.Equals(b.Angle))
+ && (!radius || a.Radius.Equals(b.Radius));
+ }
+
+ // A lead with its lengths converted; angles and the arc sweep are not lengths.
+ private static LeadIn Scaled(LeadIn leadIn, double scale) => scale == 1 ? leadIn : leadIn switch
+ {
+ LineLeadIn line => new LineLeadIn { Length = line.Length * scale, ApproachAngle = line.ApproachAngle },
+ ArcLeadIn arc => new ArcLeadIn { Radius = arc.Radius * scale, SweepAngle = arc.SweepAngle },
+ LineArcLeadIn lineArc => new LineArcLeadIn
+ {
+ LineLength = lineArc.LineLength * scale,
+ ArcRadius = lineArc.ArcRadius * scale,
+ ApproachAngle = lineArc.ApproachAngle,
+ },
+ CleanHoleLeadIn cleanHole => new CleanHoleLeadIn
+ {
+ LineLength = cleanHole.LineLength * scale,
+ ArcRadius = cleanHole.ArcRadius * scale,
+ Kerf = cleanHole.Kerf * scale,
+ },
+ LineLineLeadIn lineLine => new LineLineLeadIn
+ {
+ Length1 = lineLine.Length1 * scale,
+ ApproachAngle1 = lineLine.ApproachAngle1,
+ Length2 = lineLine.Length2 * scale,
+ ApproachAngle2 = lineLine.ApproachAngle2,
+ },
+ _ => leadIn,
+ };
+
+ private static LeadOut Scaled(LeadOut leadOut, double scale) => scale == 1 ? leadOut : leadOut switch
+ {
+ LineLeadOut line => new LineLeadOut { Length = line.Length * scale, ApproachAngle = line.ApproachAngle },
+ ArcLeadOut arc => new ArcLeadOut { Radius = arc.Radius * scale },
+ _ => leadOut,
+ };
+
///
/// Gives the nest's plate defaults the part and edge spacing the machine has for its
/// material and thickness. Spacing the machine leaves unset stays as it was. Returns false,
diff --git a/OpenNest.Data/MaterialConfig.cs b/OpenNest.Data/MaterialConfig.cs
index cd0d9605..a834aa3a 100644
--- a/OpenNest.Data/MaterialConfig.cs
+++ b/OpenNest.Data/MaterialConfig.cs
@@ -6,4 +6,8 @@ public class MaterialConfig
public string Grade { get; set; } = "";
public double Density { get; set; }
public List Thicknesses { get; set; } = new();
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/MotionConfig.cs b/OpenNest.Data/MotionConfig.cs
index 8872f8c7..a6f0f257 100644
--- a/OpenNest.Data/MotionConfig.cs
+++ b/OpenNest.Data/MotionConfig.cs
@@ -39,4 +39,8 @@ public class MotionConfig
CornerDeviation = CornerDeviation * scale,
};
}
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/SharedSettings.cs b/OpenNest.Data/SharedSettings.cs
index 490ebc68..e96f5766 100644
--- a/OpenNest.Data/SharedSettings.cs
+++ b/OpenNest.Data/SharedSettings.cs
@@ -121,4 +121,110 @@ public static class SharedSettingRules
}
return null;
}
+
+ ///
+ /// Why may not replace the stored , or
+ /// null when it may. A machine saved by an OpenNest too old to know a setting would drop it:
+ /// a field the stored machine, one of its materials (matched by name and grade) or one of
+ /// their thicknesses (matched by value) holds a value for, and that the new document leaves
+ /// out entirely, refuses the save. Current builds keep fields they do not know, and write
+ /// cleared settings as null, so they never trip this.
+ ///
+ public static string? GetReplaceError(string? kind, string storedJson, string json)
+ {
+ if (kind != SharedSettingKinds.Machine)
+ return null;
+ try
+ {
+ using var stored = JsonDocument.Parse(storedJson);
+ using var incoming = JsonDocument.Parse(json);
+ var dropped = DroppedField(stored.RootElement, incoming.RootElement, "");
+ return dropped is null
+ ? null
+ : $"This machine has settings ({dropped}) that this version of OpenNest does not know, "
+ + "so saving here would erase them. Update OpenNest to change this machine.";
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ // The first field with a value in stored that incoming leaves out, as a path; materials and
+ // thicknesses are matched by identity, so a reorder or a removed entry is not a drop.
+ private static string? DroppedField(JsonElement stored, JsonElement incoming, string path)
+ {
+ if (stored.ValueKind != JsonValueKind.Object || incoming.ValueKind != JsonValueKind.Object)
+ return null;
+ foreach (var field in stored.EnumerateObject())
+ {
+ if (field.Value.ValueKind == JsonValueKind.Null)
+ continue;
+ var name = path.Length == 0 ? field.Name : $"{path}.{field.Name}";
+ if (!TryGetProperty(incoming, field.Name, out var match))
+ return name;
+ var nested = field.Name.ToLowerInvariant() switch
+ {
+ "materials" => DroppedInList(field.Value, match, name, Material),
+ "thicknesses" => DroppedInList(field.Value, match, name, Thickness),
+ _ => path.Length > 0 && path.EndsWith("]", StringComparison.Ordinal)
+ ? DroppedField(field.Value, match, name)
+ : null,
+ };
+ if (nested != null)
+ return nested;
+ }
+ return null;
+ }
+
+ private static string? DroppedInList(JsonElement stored, JsonElement incoming, string path, Func identity)
+ {
+ if (stored.ValueKind != JsonValueKind.Array || incoming.ValueKind != JsonValueKind.Array)
+ return null;
+ foreach (var item in stored.EnumerateArray())
+ {
+ var id = identity(item);
+ if (id == null)
+ continue;
+ foreach (var candidate in incoming.EnumerateArray())
+ {
+ if (identity(candidate) != id)
+ continue;
+ if (DroppedField(item, candidate, $"{path}[{id}]") is { } dropped)
+ return dropped;
+ break;
+ }
+ }
+ return null;
+ }
+
+ private static string? Material(JsonElement material) =>
+ material.ValueKind == JsonValueKind.Object
+ ? $"{Text(material, "name")}/{Text(material, "grade")}"
+ : null;
+
+ private static string? Thickness(JsonElement thickness) =>
+ thickness.ValueKind == JsonValueKind.Object && TryGetProperty(thickness, "value", out var value)
+ && value.ValueKind == JsonValueKind.Number
+ ? value.GetDouble().ToString("R", System.Globalization.CultureInfo.InvariantCulture)
+ : null;
+
+ private static string Text(JsonElement element, string name) =>
+ TryGetProperty(element, name, out var value) && value.ValueKind == JsonValueKind.String
+ ? SharedListNames.Key(value.GetString())
+ : "";
+
+ private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
+ {
+ foreach (var property in element.EnumerateObject())
+ {
+ if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ value = property.Value;
+ return true;
+ }
+ }
+ value = default;
+ return false;
+ }
}
diff --git a/OpenNest.Data/SupportGridSettings.cs b/OpenNest.Data/SupportGridSettings.cs
index e3a696ab..916fe257 100644
--- a/OpenNest.Data/SupportGridSettings.cs
+++ b/OpenNest.Data/SupportGridSettings.cs
@@ -218,4 +218,8 @@ public sealed class SupportGridSettings
return fallback;
return value;
}
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [System.Text.Json.Serialization.JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/ThicknessConfig.cs b/OpenNest.Data/ThicknessConfig.cs
index 88b93ab9..cced33b9 100644
--- a/OpenNest.Data/ThicknessConfig.cs
+++ b/OpenNest.Data/ThicknessConfig.cs
@@ -1,4 +1,6 @@
using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace OpenNest.Data;
@@ -32,4 +34,14 @@ public class ThicknessConfig
/// The edge spacing of nests in this thickness; null keeps the nest defaults'.
public EdgeSpacingConfig? EdgeSpacing { get; set; }
+
+ ///
+ /// Lead-in angle rounding, tabs and pierce for this thickness; null takes this PC's last
+ /// cutting settings.
+ ///
+ public ThicknessCuttingConfig? Cutting { get; set; }
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
}
diff --git a/OpenNest.Data/ThicknessCuttingConfig.cs b/OpenNest.Data/ThicknessCuttingConfig.cs
new file mode 100644
index 00000000..3b2ac06f
--- /dev/null
+++ b/OpenNest.Data/ThicknessCuttingConfig.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenNest.Data;
+
+///
+/// The Cutting Parameters dialog's settings beyond leads for one thickness: lead-in angle
+/// rounding, tabs and pierce. Lengths are in the machine's units. A thickness without them
+/// ( null) takes this PC's last cutting settings.
+///
+public class ThicknessCuttingConfig
+{
+ public bool RoundLeadInAngles { get; set; }
+ public double LeadInAngleIncrement { get; set; } = 5;
+
+ /// Whether parts are tabbed at all: by the support grid, or by size with .
+ public bool Tabs { get; set; }
+
+ /// With , ignore the support grid and tab parts whose narrow side is in the size range.
+ public bool TabBySize { get; set; }
+
+ public double TabSize { get; set; } = 0.03;
+
+ /// The longest part its tab alone holds; 0 turns the rule off.
+ public double TabHeldMaxLength { get; set; } = 6;
+
+ public double TabSizeMinWidth { get; set; }
+
+ /// The widest part tabbed by size; 0 tabs nothing by size.
+ public double TabSizeMaxWidth { get; set; }
+
+ public double PierceClearance { get; set; } = 0.0625;
+
+ /// The pierce zone's diameter; 0 turns it off.
+ public double PierceDiameter { get; set; }
+
+ /// Fields a newer OpenNest wrote, kept so saving here does not drop them.
+ [JsonExtensionData]
+ public Dictionary? Unknown { get; set; }
+}
diff --git a/OpenNest.Server.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs
index 8d910729..a032da67 100644
--- a/OpenNest.Server.Tests/SharedSettingsTests.cs
+++ b/OpenNest.Server.Tests/SharedSettingsTests.cs
@@ -58,6 +58,39 @@ public sealed class SharedSettingsTests
database.CreateSharedSetting("machine", Guid.NewGuid().ToString(), Write(SharedMachineProvider.Serialize(machine))));
}
+ [Fact]
+ public async Task Api_RefusesAMachineFromABuildThatWouldDropItsThicknessCuttingSettings()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var machine = new MachineConfig
+ {
+ Name = "Synthetic laser",
+ Materials =
+ {
+ new MaterialConfig
+ {
+ Name = "Mild Steel",
+ Thicknesses = { new ThicknessConfig { Value = 0.25, Cutting = new ThicknessCuttingConfig { Tabs = true } } },
+ },
+ },
+ };
+ var url = $"/api/settings/machine/{machine.Id}";
+ var json = SharedMachineProvider.Serialize(machine);
+ // What a build without per-thickness cutting settings sends back: no "cutting" field at all.
+ var old = json.Replace("\"cutting\":", "\"plateSizesNote\":", StringComparison.Ordinal);
+ machine.Materials[0].Thicknesses[0].Cutting = null;
+ var cleared = SharedMachineProvider.Serialize(machine);
+
+ Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync(url, Write(json))).StatusCode);
+ var refused = await Put(client, url, Write(old), revision: 1);
+ var allowed = await Put(client, url, Write(cleared), revision: 1);
+
+ Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
+ Assert.Contains("Update OpenNest", await refused.Content.ReadAsStringAsync());
+ Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
+ }
+
[Fact]
public void V12Upgrade_StartsWithNoSharedSettings()
{
diff --git a/OpenNest.Server/NestDatabase.Settings.cs b/OpenNest.Server/NestDatabase.Settings.cs
index 581965e7..d41871ac 100644
--- a/OpenNest.Server/NestDatabase.Settings.cs
+++ b/OpenNest.Server/NestDatabase.Settings.cs
@@ -94,6 +94,8 @@ public sealed partial class NestDatabase
throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before saving.");
if (current.Record.Json == write.Json)
return current.Record;
+ if (SharedSettingRules.GetReplaceError(kind, current.Record.Json, write.Json) is { } refused)
+ throw new InvalidOperationException(refused);
var record = WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: false, transaction);
transaction.Commit();
diff --git a/OpenNest.Server/SettingRoutes.cs b/OpenNest.Server/SettingRoutes.cs
index c28a15d9..ce36c444 100644
--- a/OpenNest.Server/SettingRoutes.cs
+++ b/OpenNest.Server/SettingRoutes.cs
@@ -50,6 +50,7 @@ internal static class SettingRoutes
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
+ catch (InvalidOperationException refused) { return Results.BadRequest(refused.Message); }
});
app.MapDelete("/api/settings/{kind}/{key}", (string kind, string key, string? savedBy, HttpRequest request, NestDatabase db) =>
diff --git a/OpenNest.Tests/Data/ThicknessCuttingSettingsTests.cs b/OpenNest.Tests/Data/ThicknessCuttingSettingsTests.cs
new file mode 100644
index 00000000..4b9d1ab6
--- /dev/null
+++ b/OpenNest.Tests/Data/ThicknessCuttingSettingsTests.cs
@@ -0,0 +1,307 @@
+using System.Text.Json;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+/// Cutting settings (rounding, tabs, pierce) kept per machine thickness.
+public class ThicknessCuttingSettingsTests
+{
+ private static readonly ThicknessCuttingConfig ThinTabs = new()
+ {
+ Tabs = true,
+ TabBySize = true,
+ TabSize = 0.03,
+ TabHeldMaxLength = 4,
+ TabSizeMinWidth = 0.5,
+ TabSizeMaxWidth = 3,
+ PierceClearance = 0.05,
+ PierceDiameter = 0.08,
+ RoundLeadInAngles = true,
+ LeadInAngleIncrement = 15,
+ };
+
+ private static MachineConfig Machine(UnitSystem units = UnitSystem.Inches, double thin = 0.25, double thick = 0.5) => new()
+ {
+ Name = "Fiber 1",
+ Units = units,
+ Materials =
+ {
+ new MaterialConfig
+ {
+ Name = "Mild Steel",
+ Grade = "A36",
+ Thicknesses =
+ {
+ new ThicknessConfig
+ {
+ Value = thin,
+ LeadIn = new LeadConfig { Type = "Line", Length = 0.125, Angle = 60 },
+ LeadOut = new LeadConfig { Type = "None" },
+ Cutting = ThinTabs,
+ },
+ new ThicknessConfig
+ {
+ Value = thick,
+ LeadIn = new LeadConfig { Type = "Line", Length = 0.25, Angle = 60 },
+ LeadOut = new LeadConfig { Type = "None" },
+ Cutting = new ThicknessCuttingConfig { Tabs = false },
+ },
+ new ThicknessConfig
+ {
+ Value = 1,
+ LeadIn = new LeadConfig { Type = "Line", Length = 0.25, Angle = 60 },
+ LeadOut = new LeadConfig { Type = "None" },
+ },
+ },
+ },
+ },
+ };
+
+ // As the machine files are read: camel case, enums by name.
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ private static Nest NestOf(double thickness, Units units = Units.Inches) =>
+ new() { Units = units, Thickness = thickness, Material = new Material("Mild Steel", "A36", 0.2836) };
+
+ private static CuttingParameters Fallback() => new()
+ {
+ SupportTabs = true,
+ TabConfig = new NormalTab { Size = 0.1 },
+ TabSizeMaxWidth = 9,
+ PierceClearance = 0.2,
+ };
+
+ [Fact]
+ public void ApplyTo_GivesEachThicknessItsOwnTabs()
+ {
+ var thin = Fallback();
+ var thick = Fallback();
+
+ Assert.True(MachineCuttingDefaults.ApplyTo(thin, Machine(), NestOf(0.25)));
+ Assert.True(MachineCuttingDefaults.ApplyTo(thick, Machine(), NestOf(0.5)));
+
+ Assert.True(thin.SupportTabs);
+ Assert.True(thin.TabBySize);
+ Assert.Equal(0.03, thin.TabConfig.Size);
+ Assert.Equal(4, thin.TabHeldMaxLength);
+ Assert.Equal(0.5, thin.TabSizeMinWidth);
+ Assert.Equal(3, thin.TabSizeMaxWidth);
+ Assert.Equal(0.05, thin.PierceClearance);
+ Assert.Equal(0.08, thin.PierceDiameter);
+ Assert.True(thin.RoundLeadInAngles);
+ Assert.Equal(15, thin.LeadInAngleIncrement);
+ Assert.False(thick.SupportTabs);
+ }
+
+ [Fact]
+ public void ApplyTo_ThicknessWithoutCuttingSettingsKeepsThisPcsSettings()
+ {
+ var parameters = Fallback();
+
+ Assert.True(MachineCuttingDefaults.ApplyTo(parameters, Machine(), NestOf(1)));
+
+ Assert.True(parameters.SupportTabs);
+ Assert.Equal(0.1, parameters.TabConfig.Size);
+ Assert.Equal(9, parameters.TabSizeMaxWidth);
+ Assert.Equal(0.2, parameters.PierceClearance);
+ Assert.Equal(0.25, ((LineLeadIn)parameters.ExternalLeadIn).Length);
+ }
+
+ [Fact]
+ public void ApplyTo_ConvertsTheThicknessLengthsToTheNestsUnits()
+ {
+ var parameters = Fallback();
+
+ Assert.True(MachineCuttingDefaults.ApplyTo(parameters, Machine(), NestOf(6.35, Units.Millimeters)));
+
+ Assert.Equal(0.03 * 25.4, parameters.TabConfig.Size, 9);
+ Assert.Equal(3 * 25.4, parameters.TabSizeMaxWidth, 9);
+ Assert.Equal(0.08 * 25.4, parameters.PierceDiameter, 9);
+ Assert.Equal(15, parameters.LeadInAngleIncrement);
+ }
+
+ [Fact]
+ public void ApplyTo_RetiresAFallbackTabEveryPart()
+ {
+ var parameters = Fallback();
+ parameters.TabsEnabled = true;
+
+ MachineCuttingDefaults.ApplyTo(parameters, Machine(), NestOf(0.5));
+
+ Assert.False(parameters.TabsEnabled);
+ }
+
+ [Fact]
+ public void WriteTo_StoresTheDialogsSettingsOnTheThickness()
+ {
+ var machine = Machine();
+ var nest = NestOf(1);
+ var parameters = Fallback();
+ MachineCuttingDefaults.ApplyTo(parameters, machine, nest);
+ parameters.ExternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 };
+ parameters.InternalLeadIn = new ArcLeadIn { Radius = 0.1 };
+ parameters.SupportTabs = false;
+ parameters.PierceDiameter = 0.07;
+
+ Assert.True(MachineCuttingDefaults.WriteTo(machine, nest, parameters));
+
+ var thickness = machine.Materials[0].Thicknesses[2];
+ Assert.Equal("Line", thickness.LeadIn.Type);
+ Assert.Equal(0.3, thickness.LeadIn.Length);
+ Assert.Equal(45, thickness.LeadIn.Angle);
+ Assert.Equal("Arc", thickness.InsideLeadIn.Type);
+ Assert.Equal(0.1, thickness.InsideLeadIn.Radius);
+ Assert.NotNull(thickness.Cutting);
+ Assert.False(thickness.Cutting.Tabs);
+ Assert.Equal(0.07, thickness.Cutting.PierceDiameter);
+
+ var reread = new CuttingParameters { SupportTabs = true };
+ MachineCuttingDefaults.ApplyTo(reread, machine, nest);
+ Assert.False(reread.SupportTabs);
+ Assert.Equal(0.3, ((LineLeadIn)reread.ExternalLeadIn).Length);
+ Assert.Equal(0.1, ((ArcLeadIn)reread.InternalLeadIn).Radius);
+ }
+
+ [Fact]
+ public void WriteTo_InsideCutsMatchingTheOutsideOnesKeepFollowingThem()
+ {
+ var machine = Machine();
+ var nest = NestOf(0.25);
+ var parameters = new CuttingParameters();
+ MachineCuttingDefaults.ApplyTo(parameters, machine, nest);
+
+ Assert.True(MachineCuttingDefaults.WriteTo(machine, nest, parameters));
+
+ var thickness = machine.Materials[0].Thicknesses[0];
+ Assert.Null(thickness.InsideLeadIn);
+ Assert.Null(thickness.InsideLeadOut);
+ }
+
+ [Fact]
+ public void WriteTo_ConvertsToTheMachinesUnitsAndKeepsLeadsItCannotHold()
+ {
+ var machine = Machine(UnitSystem.Millimeters, thin: 6.35);
+ var nest = NestOf(0.25);
+ var parameters = new CuttingParameters
+ {
+ ExternalLeadIn = new LineLineLeadIn { Length1 = 0.2, Length2 = 0.1 },
+ ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 90 },
+ InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 60 },
+ TabConfig = new NormalTab { Size = 0.04 },
+ SupportTabs = true,
+ };
+
+ Assert.True(MachineCuttingDefaults.WriteTo(machine, nest, parameters));
+
+ var thickness = machine.Materials[0].Thicknesses[0];
+ Assert.Equal("Line", thickness.LeadIn.Type);
+ Assert.Equal(0.125, thickness.LeadIn.Length);
+ Assert.Equal(2.54, thickness.LeadOut.Length, 9);
+ Assert.Equal(0.04 * 25.4, thickness.Cutting.TabSize, 9);
+ }
+
+ [Fact]
+ public void WriteTo_RefusesAThicknessTheMachineDoesNotHave()
+ {
+ var machine = Machine();
+
+ Assert.False(MachineCuttingDefaults.WriteTo(machine, NestOf(0.75), new CuttingParameters()));
+ }
+
+ [Fact]
+ public void CuttingOf_AndToCuttingConfig_RoundTrip()
+ {
+ var copy = MachineCuttingDefaults.ToCuttingConfig(MachineCuttingDefaults.CuttingOf(ThinTabs));
+
+ Assert.Equal(JsonSerializer.Serialize(ThinTabs), JsonSerializer.Serialize(copy));
+ Assert.Equal(0.03, MachineCuttingDefaults.CuttingOf(null).TabConfig.Size);
+ }
+
+ [Fact]
+ public void MachineJson_KeepsFieldsANewerOpenNestWrote()
+ {
+ var json = SharedMachineProvider.Serialize(Machine());
+ var text = json
+ .Replace("\"value\": 0.25,", "\"value\": 0.25,\n\"futureThickness\": { \"a\": 1 },")
+ .Replace("\"tabs\": true,", "\"tabs\": true,\n\"futureTab\": 7,");
+ Assert.NotEqual(json, text);
+
+ var machine = JsonSerializer.Deserialize(text, Options);
+ var saved = SharedMachineProvider.Serialize(machine);
+
+ Assert.Contains("\"futureThickness\"", saved);
+ Assert.Contains("\"futureTab\": 7", saved);
+ }
+
+ [Fact]
+ public void MachineJson_WithoutCuttingSettingsReadsAsNotSet()
+ {
+ var json = SharedMachineProvider.Serialize(Machine()).Replace("\"cutting\":", "\"oldCutting\":");
+
+ var machine = JsonSerializer.Deserialize(json, Options);
+
+ Assert.All(machine.Materials[0].Thicknesses, t => Assert.Null(t.Cutting));
+ }
+
+ [Fact]
+ public void GetReplaceError_RefusesAMachineThatDropsAThicknesssCuttingSettings()
+ {
+ var stored = SharedMachineProvider.Serialize(Machine());
+ var old = Without(stored, "cutting");
+
+ Assert.NotNull(SharedSettingRules.GetReplaceError(SharedSettingKinds.Machine, stored, old));
+ }
+
+ [Fact]
+ public void GetReplaceError_AllowsClearedSettingsRemovedThicknessesAndUnsetFields()
+ {
+ var machine = Machine();
+ var stored = SharedMachineProvider.Serialize(machine);
+
+ machine.Materials[0].Thicknesses[0].Cutting = null;
+ Assert.Null(SharedSettingRules.GetReplaceError(SharedSettingKinds.Machine, stored, SharedMachineProvider.Serialize(machine)));
+
+ machine.Materials[0].Thicknesses.RemoveAt(1);
+ Assert.Null(SharedSettingRules.GetReplaceError(SharedSettingKinds.Machine, stored, SharedMachineProvider.Serialize(machine)));
+
+ // An older build saving a machine whose thicknesses never had cutting settings is fine.
+ var plain = Machine();
+ foreach (var thickness in plain.Materials[0].Thicknesses)
+ thickness.Cutting = null;
+ var plainJson = SharedMachineProvider.Serialize(plain);
+ Assert.Null(SharedSettingRules.GetReplaceError(SharedSettingKinds.Machine, plainJson, Without(plainJson, "cutting")));
+
+ Assert.Null(SharedSettingRules.GetReplaceError("post", "{\"a\":1}", "{}"));
+ }
+
+ // The JSON with every property of that name removed, as a build that does not know it writes.
+ private static string Without(string json, string property)
+ {
+ var node = System.Text.Json.Nodes.JsonNode.Parse(json);
+ Remove(node, property);
+ return node.ToJsonString();
+ }
+
+ private static void Remove(System.Text.Json.Nodes.JsonNode node, string property)
+ {
+ switch (node)
+ {
+ case System.Text.Json.Nodes.JsonObject obj:
+ obj.Remove(property);
+ foreach (var (_, child) in obj.ToList())
+ if (child != null)
+ Remove(child, property);
+ break;
+ case System.Text.Json.Nodes.JsonArray array:
+ foreach (var child in array)
+ if (child != null)
+ Remove(child, property);
+ break;
+ }
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/MachineTabsPierceFormsTests.cs b/OpenNest.WinForms.Tests/Forms/MachineTabsPierceFormsTests.cs
new file mode 100644
index 00000000..bc056db5
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/MachineTabsPierceFormsTests.cs
@@ -0,0 +1,233 @@
+using System.Windows.Forms;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Controls;
+using OpenNest.Data;
+using OpenNest.Forms;
+using static OpenNest.WinForms.Tests.FormTesting;
+
+namespace OpenNest.WinForms.Tests.Forms;
+
+///
+/// Cutting settings kept per machine thickness: the Tabs & Pierce tab of Machine
+/// Configuration, copying them between thicknesses, and the cutting panel's machine modes.
+///
+public class MachineTabsPierceFormsTests
+{
+ [Fact]
+ public void TabsPierceTab_SetsTheThicknessesCuttingSettingsOnlyWhileChecked()
+ {
+ RunSta(() =>
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(directory);
+ try
+ {
+ var provider = new LocalJsonProvider(directory);
+ provider.SaveMachine(new MachineConfig
+ {
+ Name = "Laser",
+ Materials = { new MaterialConfig { Name = "Mild Steel", Thicknesses = { new ThicknessConfig { Value = 0.25 } } } },
+ });
+
+ using var form = new MachineConfigForm(provider);
+ var node = Descendants(Find(form, "_tree").Nodes).Single(n => n.Tag is ThicknessConfig);
+ typeof(MachineConfigForm)
+ .GetMethod("Tree_AfterSelect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
+ .Invoke(form, new object[] { null, new TreeViewEventArgs(node) });
+ var shown = (ThicknessConfig)node.Tag;
+
+ var page = All(form).Single().TabPages.Cast().Single(p => p.Text == "Tabs & Pierce");
+ var use = All(page).Single(c => c.Text == "Use for this thickness");
+ var panel = All(page).Single();
+ // A thickness edits its leads on its own tabs, so this panel has none.
+ Assert.Empty(All(page));
+ Assert.False(use.Checked);
+ Assert.False(panel.Enabled);
+ Assert.Null(shown.Cutting);
+
+ use.Checked = true;
+ Assert.NotNull(shown.Cutting);
+ Assert.False(shown.Cutting!.Tabs);
+
+ All(panel).Single(c => c.Text == "Add tabs to hold parts in the sheet").Checked = true;
+ All(panel).Single(r => r.Text == "Tab parts by size").Checked = true;
+ Assert.True(shown.Cutting!.Tabs);
+ Assert.True(shown.Cutting.TabBySize);
+
+ use.Checked = false;
+ Assert.Null(shown.Cutting);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ });
+ }
+
+ [Fact]
+ public void CopyCutting_GivesEachThicknessItsOwnCopy()
+ {
+ var source = new ThicknessCuttingConfig { Tabs = true, TabSize = 0.04, TabSizeMaxWidth = 3 };
+
+ var copy = MachineConfigForm.CopyCutting(source);
+
+ Assert.NotSame(source, copy);
+ Assert.True(copy.Tabs);
+ Assert.Equal(0.04, copy.TabSize);
+ Assert.Equal(3, copy.TabSizeMaxWidth);
+ Assert.Null(MachineConfigForm.CopyCutting(null));
+ }
+
+ [Fact]
+ public void CuttingPanel_ShowsWhereItSavesAndOffersOnlyMachineLeads()
+ {
+ RunSta(() =>
+ {
+ using var panel = new CuttingPanel
+ {
+ MachineLeads = true,
+ Source = "Saved for Fiber 1 · Mild Steel A36 0.25 in",
+ };
+ panel.LoadFromParameters(new CuttingParameters());
+
+ Assert.Contains(All