From 3cd7729d382bb8effa058ee42153c7128d5824bc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 5 Nov 2026 08:24:39 -0500 Subject: [PATCH] feat(cutting): keep cutting settings per machine material and thickness Tabs suit thin plate, but on anything thicker than 0.25 in the parts are too hard to break out of the sheet, and the Cutting Parameters dialog was one global set for every job. Its settings now live on the nest's machine thickness: leads on the existing Outside/Inside Cuts tabs, and lead-in angle rounding, tabs and pierce on a new Tabs & Pierce tab (ThicknessConfig.Cutting). In Database mode every workstation gets the same settings through the shared machine. The dialog and the Place Lead-in panel say what they edit, and save back to that thickness (MachineCuttingDefaults.WriteTo, converted to the machine's units). A thickness with no Tabs & Pierce of its own, or a nest the machine has no thickness for, keeps this PC's settings as before. If the machine can't be saved (server offline, a conflict), the edit is kept on this PC for that job and the user is told. Machine Configuration can copy one thickness's Tabs & Pierce to thinner or thicker ones, so the 0.25 in rule takes a few clicks. An older desktop saving a shared machine would silently erase these settings, since it doesn't know them. Machine configuration classes now keep unknown fields, and the server refuses a machine save that leaves out a field the stored machine has a value for, telling the user to update OpenNest. The Plan Cutting status line now reports tabs from SupportTabs; since "Tab every part" was retired it always said off. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- OpenNest.Data/CutOffConfig.cs | 4 + OpenNest.Data/EdgeSpacingConfig.cs | 4 + OpenNest.Data/LeadConfig.cs | 4 + OpenNest.Data/MachineClamps.cs | 4 + OpenNest.Data/MachineConfig.cs | 4 + OpenNest.Data/MachineCuttingDefaults.cs | 143 +++++++- OpenNest.Data/MaterialConfig.cs | 4 + OpenNest.Data/MotionConfig.cs | 4 + OpenNest.Data/SharedSettings.cs | 106 ++++++ OpenNest.Data/SupportGridSettings.cs | 4 + OpenNest.Data/ThicknessConfig.cs | 12 + OpenNest.Data/ThicknessCuttingConfig.cs | 40 +++ OpenNest.Server.Tests/SharedSettingsTests.cs | 33 ++ OpenNest.Server/NestDatabase.Settings.cs | 2 + OpenNest.Server/SettingRoutes.cs | 1 + .../Data/ThicknessCuttingSettingsTests.cs | 307 ++++++++++++++++++ .../Forms/MachineTabsPierceFormsTests.cs | 233 +++++++++++++ OpenNest/Actions/ActionLeadIn.cs | 12 +- OpenNest/Controls/CuttingPanel.cs | 57 +++- OpenNest/Forms/CuttingInspectionPanel.cs | 10 +- OpenNest/Forms/CuttingParametersDialog.cs | 30 +- OpenNest/Forms/EditNestForm.cs | 12 +- OpenNest/Forms/MachineConfigForm.cs | 131 ++++++++ OpenNest/Forms/MachineSettings.cs | 84 +++++ docs/machine-cutting-defaults.md | 50 ++- docs/shared-settings.md | 9 +- docs/support-tabs.md | 4 +- 28 files changed, 1277 insertions(+), 33 deletions(-) create mode 100644 OpenNest.Data/ThicknessCuttingConfig.cs create mode 100644 OpenNest.Tests/Data/ThicknessCuttingSettingsTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/MachineTabsPierceFormsTests.cs 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