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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-05 08:24:39 -05:00
1 parent 6de23fb831
commit 3cd7729d38
28 files changed
+1277 -33

No files matched your search

+1 -1
View File
@@ -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
+4
View File
@@ -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";
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+4
View File
@@ -7,4 +7,8 @@ public class EdgeSpacingConfig
public double Bottom { get; set; }
public double Right { get; set; }
public double Top { get; set; }
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+4
View File
@@ -6,4 +6,8 @@ public class LeadConfig
public double Length { get; set; }
public double Angle { get; set; } = 90.0;
public double Radius { get; set; }
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+4
View File
@@ -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);
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+4
View File
@@ -60,4 +60,8 @@ public class MachineConfig
/// </summary>
public IEnumerable<MaterialConfig> GetGrades(string name) =>
Materials.Where(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+141 -2
View File
@@ -46,8 +46,10 @@ public static class MachineCuttingDefaults
/// <summary>
/// Replaces the lead-ins, lead-outs and kerf of <paramref name="parameters"/> 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 (<see cref="ThicknessConfig.Cutting"/>), and records the machine, material and
/// thickness they came from. Returns false, changing nothing, when the machine has no
/// settings for the nest.
/// </summary>
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;
}
/// <summary>
/// Stores the leads, lead-in angle rounding, tabs and pierce of <paramref name="parameters"/>
/// (in the nest's units) on the machine's thickness for the nest, in the machine's units: the
/// inverse of <see cref="ApplyTo(CuttingParameters, MachineConfig?, Nest)"/>. 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.
/// </summary>
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;
}
/// <summary>
/// The thickness's lead-in angle rounding, tabs and pierce as cutting parameters in the
/// machine's units, for editing them; <see cref="ThicknessCuttingConfig"/>'s defaults when it
/// sets none.
/// </summary>
public static CuttingParameters CuttingOf(ThicknessCuttingConfig? cutting)
{
var parameters = new CuttingParameters();
ApplyCutting(parameters, cutting ?? new ThicknessCuttingConfig(), 1);
return parameters;
}
/// <summary>The thickness settings form of <paramref name="parameters"/>' rounding, tabs and pierce, unscaled.</summary>
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,
};
/// <summary>
/// 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,
+4
View File
@@ -6,4 +6,8 @@ public class MaterialConfig
public string Grade { get; set; } = "";
public double Density { get; set; }
public List<ThicknessConfig> Thicknesses { get; set; } = new();
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+4
View File
@@ -39,4 +39,8 @@ public class MotionConfig
CornerDeviation = CornerDeviation * scale,
};
}
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+106
View File
@@ -121,4 +121,110 @@ public static class SharedSettingRules
}
return null;
}
/// <summary>
/// Why <paramref name="json"/> may not replace the stored <paramref name="storedJson"/>, 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.
/// </summary>
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<JsonElement, string?> 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;
}
}
+4
View File
@@ -218,4 +218,8 @@ public sealed class SupportGridSettings
return fallback;
return value;
}
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[System.Text.Json.Serialization.JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? Unknown { get; set; }
}
+12
View File
@@ -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
/// <summary>The edge spacing of nests in this thickness; null keeps the nest defaults'.</summary>
public EdgeSpacingConfig? EdgeSpacing { get; set; }
/// <summary>
/// Lead-in angle rounding, tabs and pierce for this thickness; null takes this PC's last
/// cutting settings.
/// </summary>
public ThicknessCuttingConfig? Cutting { get; set; }
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
/// <summary>
/// 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
/// (<see cref="ThicknessConfig.Cutting"/> null) takes this PC's last cutting settings.
/// </summary>
public class ThicknessCuttingConfig
{
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5;
/// <summary>Whether parts are tabbed at all: by the support grid, or by size with <see cref="TabBySize"/>.</summary>
public bool Tabs { get; set; }
/// <summary>With <see cref="Tabs"/>, ignore the support grid and tab parts whose narrow side is in the size range.</summary>
public bool TabBySize { get; set; }
public double TabSize { get; set; } = 0.03;
/// <summary>The longest part its tab alone holds; 0 turns the rule off.</summary>
public double TabHeldMaxLength { get; set; } = 6;
public double TabSizeMinWidth { get; set; }
/// <summary>The widest part tabbed by size; 0 tabs nothing by size.</summary>
public double TabSizeMaxWidth { get; set; }
public double PierceClearance { get; set; } = 0.0625;
/// <summary>The pierce zone's diameter; 0 turns it off.</summary>
public double PierceDiameter { get; set; }
/// <summary>Fields a newer OpenNest wrote, kept so saving here does not drop them.</summary>
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; set; }
}
@@ -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()
{
+2
View File
@@ -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();
+1
View File
@@ -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) =>
@@ -0,0 +1,307 @@
using System.Text.Json;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Data;
namespace OpenNest.Tests.Data;
/// <summary>Cutting settings (rounding, tabs, pierce) kept per machine thickness.</summary>
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<MachineConfig>(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<MachineConfig>(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;
}
}
}
@@ -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;
/// <summary>
/// Cutting settings kept per machine thickness: the Tabs &amp; Pierce tab of Machine
/// Configuration, copying them between thicknesses, and the cutting panel's machine modes.
/// </summary>
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<TreeView>(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<TabControl>(form).Single().TabPages.Cast<TabPage>().Single(p => p.Text == "Tabs & Pierce");
var use = All<CheckBox>(page).Single(c => c.Text == "Use for this thickness");
var panel = All<CuttingPanel>(page).Single();
// A thickness edits its leads on its own tabs, so this panel has none.
Assert.Empty(All<LeadsEditor>(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<CheckBox>(panel).Single(c => c.Text == "Add tabs to hold parts in the sheet").Checked = true;
All<RadioButton>(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<Label>(panel), l => l.Text == "Saved for Fiber 1 · Mild Steel A36 0.25 in");
var editors = All<LeadsEditor>(panel).ToList();
Assert.Equal(2, editors.Count);
Assert.All(editors, e => Assert.False(e.AllowLineLine));
Assert.All(editors, e => Assert.False(e.ShowArcSweep));
Assert.False(editors.Single(e => e.Emphasis == ContourType.External).AllowCleanHole);
panel.Source = null;
panel.MachineLeads = false;
Assert.All(editors, e => Assert.True(e.AllowLineLine));
});
}
private static IEnumerable<TreeNode> Descendants(TreeNodeCollection nodes) =>
nodes.Cast<TreeNode>().SelectMany(n => Descendants(n.Nodes).Prepend(n));
private static T Find<T>(Control root, string name) where T : Control =>
Assert.IsType<T>(root.Controls.Find(name, true).Single());
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, Timeout.InfiniteTimeSpan, "The STA test did not complete.");
}
/// <summary>Where the Cutting Parameters dialog saves: the nest's machine thickness, or this PC.</summary>
// Shares the saved cutting settings with the cutting inspection tests, so runs with them one at a time.
[Collection("Fill operation lifetime")]
public class CuttingSettingsSaveTests
{
private static MachineConfig Machine() => new()
{
Name = "Fiber 1",
Materials =
{
new MaterialConfig
{
Name = "Mild Steel",
Grade = "A36",
Thicknesses = { new ThicknessConfig { Value = 0.25, LeadIn = new LeadConfig { Type = "Line", Length = 0.1, Angle = 60 } } },
},
},
};
private static Nest NestFor(MachineConfig machine, double thickness) => new()
{
Thickness = thickness,
Material = new Material("Mild Steel", "A36", 0.2836),
Machine = new NestMachine(machine.Id, machine.Name),
};
private static CuttingParameters Edited() => new()
{
ExternalLeadIn = new LineLeadIn { Length = 0.2, ApproachAngle = 45 },
SupportTabs = true,
TabBySize = true,
TabSizeMaxWidth = 2,
TabConfig = new NormalTab { Size = 0.03 },
};
[Fact]
public void SavesToTheMachineThicknessAndDropsThisPcsCopyForThatJob()
{
WithMachines((provider, machine) =>
{
var nest = NestFor(machine, 0.25);
var stale = new CuttingParameters();
MachineCuttingDefaults.ApplyTo(stale, machine, nest);
MachineSettings.SaveLastCuttingParameters(stale);
var warnings = new List<string>();
Assert.True(MachineSettings.SaveCuttingParameters(nest, Edited(), provider, warnings.Add));
Assert.Empty(warnings);
var thickness = provider.GetMachine(machine.Id)!.Materials[0].Thicknesses[0];
Assert.Equal(0.2, thickness.LeadIn.Length);
Assert.True(thickness.Cutting!.TabBySize);
Assert.Equal(2, thickness.Cutting.TabSizeMaxWidth);
Assert.False(MachineCuttingDefaults.IsRecordedFor(MachineSettings.LastCuttingParameters(), machine, nest));
});
}
[Fact]
public void SavesAsThisPcsSettingsWhenTheMachineHasNoSuchThickness()
{
WithMachines((provider, machine) =>
{
var nest = NestFor(machine, 0.5);
Assert.False(MachineSettings.SaveCuttingParameters(nest, Edited(), provider, _ => Assert.Fail("No warning expected.")));
Assert.Null(provider.GetMachine(machine.Id)!.Materials[0].Thicknesses[0].Cutting);
Assert.True(MachineSettings.LastCuttingParameters().TabBySize);
});
}
[Fact]
public void KeepsTheEditOnThisPcForThatJobWhenTheMachineCannotBeSaved()
{
WithMachines((provider, machine) =>
{
var nest = NestFor(machine, 0.25);
var warnings = new List<string>();
Assert.False(MachineSettings.SaveCuttingParameters(nest, Edited(), new FailingProvider(provider), warnings.Add));
Assert.Contains("Fiber 1", Assert.Single(warnings));
var last = MachineSettings.LastCuttingParameters();
Assert.True(MachineCuttingDefaults.IsRecordedFor(last, machine, nest));
Assert.True(last.TabBySize);
// Recorded for the job, so the machine's settings do not replace the edit here.
Assert.Equal(0.2, ((LineLeadIn)MachineSettings.LastCuttingParameters().ExternalLeadIn).Length);
});
}
private static void WithMachines(Action<LocalJsonProvider, MachineConfig> test)
{
var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(directory);
var settings = OpenNest.Properties.Settings.Default;
var saved = settings.CuttingParametersJson;
try
{
var provider = new LocalJsonProvider(directory);
var machine = Machine();
provider.SaveMachine(machine);
test(provider, machine);
}
finally
{
settings.CuttingParametersJson = saved;
Directory.Delete(directory, recursive: true);
}
}
private sealed class FailingProvider(IDataProvider inner) : IDataProvider
{
public IReadOnlyList<MachineSummary> GetMachines() => inner.GetMachines();
public MachineConfig? GetMachine(Guid id) => inner.GetMachine(id);
public void SaveMachine(MachineConfig machine) => throw new IOException("The nest server could not be reached.");
public void DeleteMachine(Guid id) => inner.DeleteMachine(id);
}
}
+10 -2
View File
@@ -104,6 +104,8 @@ namespace OpenNest.Actions
cuttingPanel = new CuttingPanel();
cuttingPanel.ParametersChanged += OnToolParametersChanged;
cuttingPanel.MachineLeads = MachineSettings.HasMachineThickness(form.Nest);
cuttingPanel.Source = MachineSettings.DescribeCuttingSource(form.Nest);
cuttingPanel.LoadFromParameters(MachineSettings.CuttingParametersFor(plateView.Plate, form.Nest));
loadedParameters = CuttingParametersSerializer.Serialize(cuttingPanel.BuildParameters());
@@ -139,8 +141,14 @@ namespace OpenNest.Actions
var parameters = cuttingPanel.BuildParameters();
var json = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
// Saved only when edited, so opening and closing the tool never writes the machine.
if (json != loadedParameters)
{
if (plateView.FindForm() is EditNestForm editor)
editor.SaveCuttingParameters(parameters);
else
MachineSettings.SaveLastCuttingParameters(parameters);
}
// A plate takes the tool's parameters with its first lead-in; after that an edit in
// the panel is a change of its own. Closing the tool otherwise changes nothing.
+55 -2
View File
@@ -36,6 +36,9 @@ namespace OpenNest.Controls
private CuttingParameters loaded;
private readonly Label sourceLabel;
private bool machineLeads;
private readonly CheckBox chkRoundLeadInAngles;
private readonly NumericUpDown nudLeadInAngleIncrement;
@@ -73,6 +76,13 @@ namespace OpenNest.Controls
}
public CuttingPanel()
: this(showLeads: true) { }
/// <param name="showLeads">
/// False leaves out the lead-in and lead-out editors (Machine Configuration edits a
/// thickness's leads on their own tabs); lead-in angle rounding stays.
/// </param>
public CuttingPanel(bool showLeads)
{
AutoScroll = true;
BackColor = Color.White;
@@ -87,8 +97,19 @@ namespace OpenNest.Controls
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
// Where these settings are saved: the nest's machine thickness, or this PC.
sourceLabel = new Label
{
AutoSize = true,
ForeColor = HintColor,
Margin = new Padding(0, 6, 0, 0),
Visible = false,
};
layout.Controls.Add(sourceLabel);
// Leads: one tab per contour type, sized for the type with the most fields.
AddSection("Leads");
if (showLeads)
AddSection("Leads");
tabControl = new TabControl
{
@@ -110,7 +131,8 @@ namespace OpenNest.Controls
tabControl.Controls.Add(tabExternal);
tabControl.Controls.Add(tabInternal);
layout.Controls.Add(tabControl);
if (showLeads)
layout.Controls.Add(tabControl);
chkRoundLeadInAngles = new CheckBox
{
@@ -273,6 +295,37 @@ namespace OpenNest.Controls
public override Size GetPreferredSize(Size proposedSize) => layout.GetPreferredSize(proposedSize);
/// <summary>Where these settings are saved, shown above them; null or empty hides the line.</summary>
public string Source
{
get => sourceLabel.Text;
set
{
sourceLabel.Text = value ?? "";
sourceLabel.Visible = !string.IsNullOrEmpty(value);
}
}
/// <summary>
/// Offers only the leads a machine thickness can hold: no Line + Line, no arc sweep (leads
/// are half circles), a clean hole's kerf from the thickness and no clean hole outside.
/// </summary>
public bool MachineLeads
{
get => machineLeads;
set
{
machineLeads = value;
foreach (var leads in new[] { externalLeads, internalLeads })
{
leads.AllowLineLine = !value;
leads.ShowArcSweep = !value;
leads.ShowCleanHoleKerf = !value;
}
externalLeads.AllowCleanHole = !value;
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
+7 -3
View File
@@ -1254,12 +1254,16 @@ internal sealed class CuttingInspectionPanel : UserControl
{
// A running plan keeps going behind the dialog; OK cancels it and replans with the new settings.
if (closed || closeRequested || pendingMode != null || Mode != CuttingInspectionMode.Plan) return;
using var dialog = new CuttingParametersDialog();
using var dialog = new CuttingParametersDialog
{
MachineLeads = MachineSettings.HasMachineThickness(editor.Nest),
Source = MachineSettings.DescribeCuttingSource(editor.Nest),
};
dialog.LoadParameters(parameters);
if (dialog.ShowDialog(this) != DialogResult.OK || closed || closeRequested) return;
parameters = dialog.GetParameters();
// Saved now, not only on Apply: a plate that will not plan must not lose the edit.
EditNestForm.SaveCuttingParameters(parameters);
editor.SaveCuttingParameters(parameters);
ShowSettings();
RestartPlanning();
}
@@ -1267,7 +1271,7 @@ internal sealed class CuttingInspectionPanel : UserControl
private void ShowSettings()
{
settingsLabel.Text = $"Outside: {Describe(parameters.ExternalLeadIn)} · Holes: {Describe(parameters.InternalLeadIn)} · "
+ $"Tabs: {(parameters.TabsEnabled ? "on" : "off")}";
+ $"Tabs: {(parameters.SupportTabs ? parameters.TabBySize ? "by size" : "support grid" : "off")}";
}
private static string Describe(LeadIn leadIn)
+29 -1
View File
@@ -50,8 +50,22 @@ namespace OpenNest.Forms
AcceptButton = btnOk;
CancelButton = btnCancel;
this.buttonPanel = buttonPanel;
FitContent();
}
// Fit the whole panel so nothing scrolls, unless the screen is too short for it.
private readonly FlowLayoutPanel buttonPanel;
// Sized again once shown, since the source line set after construction adds a row.
protected override void OnLoad(System.EventArgs e)
{
FitContent();
base.OnLoad(e);
}
// Fit the whole panel so nothing scrolls, unless the screen is too short for it.
private void FitContent()
{
var content = cuttingPanel.GetPreferredSize(Size.Empty);
var maxHeight = Screen.FromPoint(Cursor.Position).WorkingArea.Height - 80;
ClientSize = new Size(
@@ -60,6 +74,20 @@ namespace OpenNest.Forms
);
}
/// <summary>Where the settings are saved, shown above them.</summary>
public string Source
{
get => cuttingPanel.Source;
set => cuttingPanel.Source = value;
}
/// <summary>Offers only the leads a machine thickness can hold (see <see cref="CuttingPanel.MachineLeads"/>).</summary>
public bool MachineLeads
{
get => cuttingPanel.MachineLeads;
set => cuttingPanel.MachineLeads = value;
}
public void LoadParameters(CuttingParameters parameters)
{
cuttingPanel.LoadFromParameters(parameters);
+6 -6
View File
@@ -1454,12 +1454,12 @@ namespace OpenNest.Forms
PlateView.SetAction(typeof(Actions.ActionLeadIn));
}
internal static void SaveCuttingParameters(CuttingParameters parameters)
{
var json = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
}
/// <summary>
/// Saves cutting settings edited for this nest: on its machine's thickness when it has one,
/// otherwise as this PC's (see <see cref="MachineSettings.SaveCuttingParameters"/>).
/// </summary>
internal void SaveCuttingParameters(CuttingParameters parameters) =>
MachineSettings.SaveCuttingParameters(this, Nest, parameters);
private void ImportDrawings_Click(object sender, EventArgs e)
{
+131
View File
@@ -119,6 +119,9 @@ namespace OpenNest.Forms
private static string GradeLabel(MaterialConfig material) =>
string.IsNullOrWhiteSpace(material.Grade) ? "(no grade)" : material.Grade;
private static string GradeLabelOf(MaterialConfig material) =>
string.IsNullOrWhiteSpace(material.Grade) ? material.Name : $"{material.Name} {material.Grade}";
private static TreeNode BuildThicknessNode(ThicknessConfig thickness) =>
new(ThicknessLabel(thickness)) { Tag = thickness };
@@ -193,6 +196,8 @@ namespace OpenNest.Forms
menu.Items.Add("Delete Grade", null, RemoveGrade_Click);
break;
case ThicknessConfig:
menu.Items.Add("Copy Tabs && Pierce To...", null, CopyCuttingSettings_Click);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("Delete Thickness", null, RemoveThickness_Click);
break;
default:
@@ -622,6 +627,7 @@ namespace OpenNest.Forms
ControlFactory.Tab("General", BuildThicknessGeneralTab(thickness, node)));
tabs.TabPages.Add(ControlFactory.Tab("Outside Cuts", BuildOutsideCutsTab(thickness)));
tabs.TabPages.Add(ControlFactory.Tab("Inside Cuts", BuildInsideCutsTab(thickness)));
tabs.TabPages.Add(ControlFactory.Tab("Tabs & Pierce", BuildTabsPierceTab(thickness)));
tabs.TabPages.Add(ControlFactory.Tab("Cut Off", BuildCutOffTab(thickness.CutOff)));
tabs.TabPages.Add(ControlFactory.Tab("Spacing", BuildSpacingTab(thickness)));
tabs.TabPages.Add(ControlFactory.Tab("Plate Sizes", BuildPlateSizesTab(thickness)));
@@ -855,6 +861,131 @@ namespace OpenNest.Forms
return page;
}
// Lead-in angle rounding, tabs and pierce for this thickness; unchecked, nests in it take
// the cutting settings last used on each PC. The Cutting Parameters dialog saves here too.
private Control BuildTabsPierceTab(ThicknessConfig thickness)
{
var useBox = new CheckBox
{
Text = "Use for this thickness",
AutoSize = true,
Dock = DockStyle.Top,
Padding = new Padding(10, 4, 0, 0),
Checked = thickness.Cutting != null,
};
var panel = new Controls.CuttingPanel(showLeads: false)
{
Dock = DockStyle.Fill,
Enabled = useBox.Checked,
};
panel.LoadFromParameters(MachineCuttingDefaults.CuttingOf(thickness.Cutting));
panel.ParametersChanged += (s, e) =>
{
if (!useBox.Checked)
return;
thickness.Cutting = MachineCuttingDefaults.ToCuttingConfig(panel.BuildParameters(), thickness.Cutting);
MarkDirty();
};
useBox.CheckedChanged += (s, e) =>
{
panel.Enabled = useBox.Checked;
thickness.Cutting = useBox.Checked
? MachineCuttingDefaults.ToCuttingConfig(panel.BuildParameters(), thickness.Cutting)
: null;
MarkDirty();
};
var page = new Panel { Dock = DockStyle.Fill, BackColor = Color.White };
page.Controls.Add(panel);
page.Controls.Add(useBox);
page.Controls.Add(CreateTabNote(
$"Lengths in {LengthUnitSuffix(null)}. Unchecked, nests in this thickness use the cutting settings last used on each PC."));
return page;
}
// Copies the selected thickness's tabs and pierce (or their absence) to other thicknesses
// of this machine, so a rule such as "tabs only up to 0.25 in" takes a few clicks.
private void CopyCuttingSettings_Click(object sender, EventArgs e)
{
if (_tree.SelectedNode?.Tag is not ThicknessConfig source || _currentMachine is null)
return;
var targets = _currentMachine.Materials
.SelectMany(m => m.Thicknesses.Where(t => !ReferenceEquals(t, source)).Select(t => (Material: m, Thickness: t)))
.OrderBy(x => x.Material.Name, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(x => x.Material.Grade, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(x => x.Thickness.Value)
.ToList();
if (targets.Count == 0)
return;
using var dialog = new Form
{
Text = $"Copy Tabs & Pierce of {ThicknessLabel(source)}",
FormBorderStyle = FormBorderStyle.FixedDialog,
MaximizeBox = false,
MinimizeBox = false,
ShowInTaskbar = false,
StartPosition = FormStartPosition.CenterParent,
ClientSize = new Size(420, 420),
};
var list = new CheckedListBox { Dock = DockStyle.Fill, CheckOnClick = true, IntegralHeight = false };
foreach (var (material, thickness) in targets)
list.Items.Add($"{GradeLabelOf(material)} · {ThicknessLabel(thickness)}");
void Select(Func<ThicknessConfig, bool> pick)
{
for (var i = 0; i < targets.Count; i++)
list.SetItemChecked(i, pick(targets[i].Thickness));
}
var pickers = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(4) };
pickers.Controls.Add(new Label
{
Text = $"{(source.Cutting == null ? "No tabs & pierce of its own" : "Its tabs & pierce")} to:",
AutoSize = true,
Margin = new Padding(3, 8, 6, 0),
});
foreach (var (text, pick) in new (string, Func<ThicknessConfig, bool>)[]
{
("Thinner", t => t.Value < source.Value),
("Thicker", t => t.Value > source.Value),
("All", _ => true),
("None", _ => false),
})
{
var button = new Button { Text = text, AutoSize = true };
button.Click += (s2, e2) => Select(pick);
pickers.Controls.Add(button);
}
var ok = new Button { Text = "Copy", DialogResult = DialogResult.OK, Size = new Size(80, 28) };
var cancel = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, Size = new Size(80, 28) };
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(8),
};
buttons.Controls.Add(cancel);
buttons.Controls.Add(ok);
dialog.Controls.Add(list);
dialog.Controls.Add(pickers);
dialog.Controls.Add(buttons);
dialog.AcceptButton = ok;
dialog.CancelButton = cancel;
if (dialog.ShowDialog(this) != DialogResult.OK || list.CheckedIndices.Count == 0)
return;
foreach (int index in list.CheckedIndices)
targets[index].Thickness.Cutting = CopyCutting(source.Cutting);
MarkDirty();
}
internal static ThicknessCuttingConfig CopyCutting(ThicknessCuttingConfig cutting) =>
cutting == null ? null : MachineCuttingDefaults.ToCuttingConfig(MachineCuttingDefaults.CuttingOf(cutting), cutting);
// The machine holds no arc sweep (leads are half circles) and takes a clean hole's kerf
// from the thickness; Line + Line is never applied from it, so it is not offered.
private Controls.LeadsEditor CreateLeadsEditor(CNC.CuttingStrategy.ContourType emphasis)
+84
View File
@@ -221,6 +221,90 @@ namespace OpenNest.Forms
? plate.CuttingParameters
: DefaultCuttingParameters(nest);
/// <summary>Whether the cutting settings of <paramref name="nest"/> are kept on its machine's thickness.</summary>
public static bool HasMachineThickness(Nest nest) =>
nest != null && MachineCuttingDefaults.Find(FindMachine(nest.Machine), nest) != null;
/// <summary>
/// Where the cutting settings of <paramref name="nest"/> are kept, for the settings
/// dialogs: the machine's thickness, or why this PC's settings are used instead.
/// </summary>
public static string DescribeCuttingSource(Nest nest)
{
var machine = FindMachine(nest?.Machine);
if (nest == null || machine == null)
return "This PC's settings: the nest has no machine.";
var material = nest.Material?.Name;
if (string.IsNullOrWhiteSpace(material) || !double.IsFinite(nest.Thickness) || nest.Thickness <= 0)
return "This PC's settings: the nest has no material and thickness.";
var job = $"{material}{(string.IsNullOrWhiteSpace(nest.Material.Grade) ? "" : " " + nest.Material.Grade)} "
+ $"{nest.Thickness:0.####} {(nest.Units == Units.Millimeters ? "mm" : "in")}";
return MachineCuttingDefaults.Find(machine, nest) == null
? $"This PC's settings: {machine.Name} has no {job}."
: $"Saved for {machine.Name} · {job}";
}
/// <summary>
/// Saves cutting settings edited for <paramref name="nest"/>: on the machine's thickness
/// when it has one, otherwise as this PC's settings. When the machine cannot be saved
/// (the nest server is offline, or someone else changed the machine), the settings are
/// kept on this PC for this job and the user is told why. Returns whether they reached the machine.
/// </summary>
public static bool SaveCuttingParameters(IWin32Window owner, Nest nest, CuttingParameters parameters) =>
SaveCuttingParameters(nest, parameters, CreateMachineProvider(), error => MessageBox.Show(owner, error,
"Cutting Parameters", MessageBoxButtons.OK, MessageBoxIcon.Warning));
/// <summary>
/// <see cref="SaveCuttingParameters(IWin32Window, Nest, CuttingParameters)"/> with the
/// machines of <paramref name="provider"/>, telling <paramref name="warn"/> why a machine
/// save failed.
/// </summary>
internal static bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action<string> warn)
{
MachineConfig machine = null;
string error;
try
{
machine = nest?.Machine == null ? null : provider.GetMachine(nest.Machine.Id);
if (machine == null || !MachineCuttingDefaults.WriteTo(machine, nest, parameters))
{
SaveLastCuttingParameters(parameters);
return false;
}
provider.SaveMachine(machine);
// A copy kept on this PC for this job would hide the machine's from now on.
var last = ReadLastCuttingParameters();
if (last != null && MachineCuttingDefaults.IsRecordedFor(last, machine, nest))
{
last.MachineName = null;
SaveLastCuttingParameters(last);
}
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
error = ex.Message;
}
// Stamped for this job, so the edit sticks on this PC until the machine takes it.
var kept = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
kept.MachineName = machine?.Name ?? nest.Machine.Name;
kept.MaterialName = nest.Material?.Name;
kept.Grade = nest.Material?.Grade;
kept.Thickness = nest.Thickness;
SaveLastCuttingParameters(kept);
warn($"The cutting settings could not be saved to {kept.MachineName}:\n\n{error}\n\n"
+ "They are kept on this PC for this material and thickness.");
return false;
}
/// <summary>Saves <paramref name="parameters"/> as this PC's last cutting settings.</summary>
public static void SaveLastCuttingParameters(CuttingParameters parameters)
{
Properties.Settings.Default.CuttingParametersJson = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.Save();
}
/// <summary>The cutting parameters last applied, or the built-in defaults when none are saved or readable.</summary>
public static CuttingParameters LastCuttingParameters() =>
ReadLastCuttingParameters() ?? new CuttingParameters();
+37 -13
View File
@@ -1,8 +1,8 @@
# Machine cutting defaults
A nest's assigned machine (`Nest.Machine`, see [nest file format](nest-file-format.md))
supplies the starting lead-ins, lead-outs, kerf, cut-off settings and spacing for the nest's
material and thickness, from that machine's Tools > Machine Configuration entry (in
supplies the lead-ins, lead-outs, kerf, lead-in angle rounding, tabs, pierce, cut-off settings
and spacing for the nest's material and thickness, from that machine's Tools > Machine Configuration entry (in
Database mode, the server's [shared machines](shared-settings.md) once it holds them).
`MachineCuttingDefaults` (OpenNest.Data) holds the mapping; the desktop applies it.
@@ -15,18 +15,42 @@ decimals (PEP stores 3/16 as 0.188) finds the machine's 0.1875; the nearest one
file, nothing changes and the previous behavior applies. Lengths are converted from the
machine's units to the nest's.
## Lead-ins and lead-outs
## Where the Cutting Parameters dialog saves
Plan Cutting and Place Lead-in start from the user's last applied parameters, with the machine's
leads and kerf laid over them and the machine, material, grade and thickness recorded on the
parameters. A plate's own parameters are used only when no user parameters are saved or readable,
so an edit reaches nests already planned. When the last parameters
already record the same machine, material, grade and thickness, the machine's settings are not
laid over them again, so lead and tab edits made for that job stick; the cutting panel keeps the
record (and the kerf) through an edit. Plan Cutting saves its settings as the last parameters as
soon as its settings dialog is accepted, not only on Apply. Changing the settings, machine,
material or thickness does not rewrite the leads a plate was planned with: its programs and
stored parameters stay until a plan is applied.
Every setting in Plan Cutting's Cutting Parameters dialog and the Place Lead-in panel is kept on
the nest's machine thickness: leads on the Outside Cuts and Inside Cuts tabs, lead-in angle
rounding, tabs and pierce on the Tabs & Pierce tab (`ThicknessConfig.Cutting`). So 0.25 in can
tab parts while 0.5 in tabs none, and in Database mode every workstation gets the same
settings. The dialog names what it edits above its fields ("Saved for Fiber 1 · Mild Steel A36
0.25 in") or why it edits this PC's settings instead (no machine, no material or thickness, or
a thickness the machine does not list).
Plan Cutting and Place Lead-in start from this PC's last settings (`CuttingParametersJson` in
user-settings.json), with the machine's leads and kerf laid over them, and its rounding, tabs and
pierce when the thickness sets them (a thickness whose Tabs & Pierce box is unchecked keeps this
PC's), and the machine, material, grade and thickness recorded on the parameters. A plate's own
parameters are used only when no user parameters are saved or readable, so an edit reaches nests
already planned.
The dialog saves when it is accepted and when a plan is applied, and Place Lead-in when its panel
closes after an edit. With a matching thickness the settings are written to the machine
(`MachineCuttingDefaults.WriteTo`, converted to the machine's units; inside cuts equal to the
outside ones keep following them, and kerf, cut-off, spacing and plate sizes are untouched), and
this PC's settings are left alone, so a thin job's tabs never become another job's. Matched, the
dialog offers only the leads a thickness can hold: no Line + Line, no arc sweep, no outside Clean
Hole. Without a matching thickness the settings are saved as this PC's.
When the machine cannot be saved (the nest server is offline, someone else changed the machine,
or the thickness was removed), the settings are saved as this PC's, recorded for that machine,
material, grade and thickness, and the user is told. While they are recorded for that job the
machine's settings are not laid over them, so the edit sticks on this PC; the next successful
save to the machine clears the record. Changing the settings, machine, material or thickness does
not rewrite the leads a plate was planned with: its programs and stored parameters stay until a
plan is applied.
Tools > Machine Configuration's thickness menu has Copy Tabs & Pierce To..., which copies one
thickness's Tabs & Pierce (or their absence) to the thicknesses picked, with Thinner and Thicker
shortcuts.
Each thickness holds a lead-in and lead-out for outside cuts (the Outside Cuts tab, external
contours) and, optionally, its own pair for inside cuts (the Inside Cuts tab, internal contours,
+7 -2
View File
@@ -6,7 +6,7 @@ building nests and posts the same way:
| Kind | Key | What it holds | This PC's own copy |
|---|---|---|---|
| `machine` | machine id | A Tools > Machine Configuration entry: materials, grades, thicknesses, leads, kerf, cut-offs, motion limits, plate sizes, support grid, clamps | `%APPDATA%\OpenNest\Machines\<id>.json` |
| `machine` | machine id | A Tools > Machine Configuration entry: materials, grades, thicknesses, leads, kerf, lead-in rounding, tabs, pierce, cut-offs, motion limits, plate sizes, support grid, clamps | `%APPDATA%\OpenNest\Machines\<id>.json` |
| `post` | post type name, e.g. `OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor` | A post's settings, as its JSON file holds them | `%APPDATA%\OpenNest\Posts\<post>.json`, else the shipped `<post>.json` beside the post DLL in `Posts\` until the first save (an update replaces the install folder, so edits are never saved there) |
| `clamps` | `default` | Legacy clamp settings, read only to copy them onto a machine once (machines now hold their clamps) | `%APPDATA%\OpenNest\clamps.json` |
| `nest-defaults` | `default` | Tools > Nest Defaults: units, sheet size, quadrant, spacing, default machine | `%APPDATA%\OpenNest\defaults.json` |
@@ -73,7 +73,12 @@ trace or recover a change.
The server checks the key against the kind (a machine id, a post type name of letters,
digits and `._-+`, or `default`), that `json` is a JSON object of at most 512 K characters,
and that a machine's JSON is a machine configuration with the key's id. It cannot check a
and that a machine's JSON is a machine configuration with the key's id. A machine PUT that
leaves out a field the stored machine, one of its materials (by name and grade) or one of their
thicknesses (by value) holds a value for is refused with 400: it comes from an OpenNest too old
to know that setting, and saving it would erase the setting (`SharedSettingRules.GetReplaceError`).
Current builds keep the fields they do not know (`[JsonExtensionData]` on the machine
configuration classes) and write cleared settings as null, so only older builds are refused. It cannot check a
post's settings beyond that; the desktop only sends what the post serialized. Recreating a
deleted document continues its revision count. Kinds a client does not know are ignored, so
a newer server can share more.
+3 -1
View File
@@ -83,7 +83,9 @@ Cutting inspects a proposal, the overlay shows the proposal's parts on the same
## Support tabs in Plan Cutting
Cutting Settings > Tabs > "Add tabs to hold parts in the sheet" sets
`CuttingParameters.SupportTabs`, and its two choices say which parts need a tab:
`CuttingParameters.SupportTabs`, and its two choices say which parts need a tab. Like every
cutting setting they are kept per machine thickness (Tools > Machine Configuration > Tabs &
Pierce), so thick plate can go untabbed; see [machine cutting defaults](machine-cutting-defaults.md):
- "Only tab parts the support grid can't hold level": the plate's support bed decides. A plate
with no bed falls back to the size range ([below](#tabbing-by-size)).