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