diff --git a/OpenNest.Core/DrawingProgramSnapshot.cs b/OpenNest.Core/DrawingProgramSnapshot.cs
new file mode 100644
index 0000000..d25c09e
--- /dev/null
+++ b/OpenNest.Core/DrawingProgramSnapshot.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.CNC;
+
+namespace OpenNest;
+
+///
+/// Captures drawing program text before an edit and rebuilds only the parts whose
+/// drawing program changed. Drawing keys use reference identity because names are editable.
+///
+public sealed class DrawingProgramSnapshot
+{
+ private readonly Dictionary programs;
+ private readonly Func fingerprint;
+
+ private DrawingProgramSnapshot(
+ Dictionary programs,
+ Func fingerprint)
+ {
+ this.programs = programs;
+ this.fingerprint = fingerprint;
+ }
+
+ ///
+ /// Capture before handing drawings to an editor, including its load operation:
+ /// programs can be edited in place. The callback must include both the main
+ /// program text and its hole sub-programs, and must not mutate the program.
+ ///
+ public static DrawingProgramSnapshot Capture(
+ IEnumerable drawings,
+ Func fingerprint)
+ {
+ ArgumentNullException.ThrowIfNull(drawings);
+ ArgumentNullException.ThrowIfNull(fingerprint);
+ var programs = new Dictionary(ReferenceEqualityComparer.Instance);
+
+ foreach (var drawing in drawings)
+ {
+ if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
+ programs.Add(drawing, fingerprint(drawing.Program));
+ }
+
+ return new DrawingProgramSnapshot(programs, fingerprint);
+ }
+
+ ///
+ /// Complete the captured edit by rebuilding changed drawings' parts across all plates.
+ /// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
+ /// Unchanged and uncaptured drawings' parts retain their program instances and state.
+ /// Returns the rebuilt parts so a UI can invalidate just their graphics.
+ ///
+ public IReadOnlyList UpdateChangedParts(IEnumerable plates)
+ {
+ ArgumentNullException.ThrowIfNull(plates);
+ var changed = new HashSet(ReferenceEqualityComparer.Instance);
+
+ foreach (var entry in programs)
+ {
+ if (!entry.Key.IsCutOff
+ && !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
+ changed.Add(entry.Key);
+ }
+
+ var updated = new List();
+ foreach (var plate in plates)
+ {
+ foreach (var part in plate.Parts)
+ {
+ if (!changed.Contains(part.BaseDrawing))
+ continue;
+
+ part.Update();
+ updated.Add(part);
+ }
+ }
+
+ return updated;
+ }
+}
diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs
index 41b66f0..9991ad7 100644
--- a/OpenNest.Core/Part.cs
+++ b/OpenNest.Core/Part.cs
@@ -111,6 +111,30 @@ namespace OpenNest
UpdateBounds();
}
+ ///
+ /// Installs an owned, already-rotated saved cutting program without rotating it again.
+ /// The current pose remains the clean-drawing pose used by RemoveLeadIns.
+ ///
+ public bool RestoreLeadInProgram(Program program, bool locked)
+ {
+ if (program == null || !program.Codes.Any(code => code is Motion
+ || code is SubProgramCall call && call.Program != null
+ && call.Program.Codes.Any(subCode => subCode is Motion)))
+ return false;
+
+ // Compute before changing state, so a malformed program cannot half-install.
+ var bounds = program.BoundingBox();
+ bounds.Offset(Location);
+ preLeadInRotation = Rotation;
+ Program = program;
+ ownsProgram = true;
+ HasManualLeadIns = true;
+ LeadInsLocked = locked;
+ CuttingParameters = null;
+ BoundingBox = bounds;
+ return true;
+ }
+
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
diff --git a/OpenNest.IO.Tests/CuttingParametersSerializerTests.cs b/OpenNest.IO.Tests/CuttingParametersSerializerTests.cs
new file mode 100644
index 0000000..619203b
--- /dev/null
+++ b/OpenNest.IO.Tests/CuttingParametersSerializerTests.cs
@@ -0,0 +1,408 @@
+using System.Text.Json;
+using OpenNest.CNC.CuttingStrategy;
+using CuttingParametersSerializer = OpenNest.IO.CuttingParametersSerializer;
+
+namespace OpenNest.IO.Tests;
+
+public class CuttingParametersSerializerTests
+{
+ [Theory]
+ [InlineData("None")]
+ [InlineData("Line")]
+ [InlineData("Arc")]
+ [InlineData("LineArc")]
+ [InlineData("CleanHole")]
+ [InlineData("LineLine")]
+ public void SettingsRoundTrip_PreservesEveryLeadInType(string type)
+ {
+ var leadIn = CreateLeadIn(type);
+ var original = new CuttingParameters
+ {
+ ExternalLeadIn = leadIn,
+ InternalLeadIn = leadIn,
+ ArcCircleLeadIn = leadIn,
+ TabConfig = new NormalTab { Size = 0.42, TabLeadIn = leadIn },
+ };
+
+ var restored = RoundTrip(original);
+
+ AssertEquivalent(leadIn, restored.ExternalLeadIn);
+ AssertEquivalent(leadIn, restored.InternalLeadIn);
+ AssertEquivalent(leadIn, restored.ArcCircleLeadIn);
+ AssertEquivalent(leadIn, restored.TabConfig.TabLeadIn);
+ }
+
+ [Theory]
+ [InlineData("None")]
+ [InlineData("Line")]
+ [InlineData("Arc")]
+ public void SettingsRoundTrip_PreservesEveryLeadOutType(string type)
+ {
+ var leadOut = CreateLeadOut(type);
+ var original = new CuttingParameters
+ {
+ ExternalLeadOut = leadOut,
+ InternalLeadOut = leadOut,
+ ArcCircleLeadOut = leadOut,
+ TabConfig = new NormalTab { Size = 0.42, TabLeadOut = leadOut },
+ };
+
+ var restored = RoundTrip(original);
+
+ AssertEquivalent(leadOut, restored.ExternalLeadOut);
+ AssertEquivalent(leadOut, restored.InternalLeadOut);
+ AssertEquivalent(leadOut, restored.ArcCircleLeadOut);
+ AssertEquivalent(leadOut, restored.TabConfig.TabLeadOut);
+ }
+
+ [Theory]
+ [InlineData("Normal")]
+ [InlineData("Machine")]
+ [InlineData("Breaker")]
+ public void SettingsRoundTrip_PreservesEveryTabType(string type)
+ {
+ var original = new CuttingParameters
+ {
+ TabsEnabled = true,
+ TabConfig = CreateTab(type),
+ };
+
+ AssertEquivalent(original, RoundTrip(original));
+ }
+
+ [Theory]
+ [InlineData(SequenceMethod.RightSide)]
+ [InlineData(SequenceMethod.LeastCode)]
+ [InlineData(SequenceMethod.Advanced)]
+ [InlineData(SequenceMethod.BottomSide)]
+ [InlineData(SequenceMethod.EdgeStart)]
+ [InlineData(SequenceMethod.LeftSide)]
+ [InlineData(SequenceMethod.RightSideAlt)]
+ public void SettingsRoundTrip_PreservesAllFieldsAndSequenceMethods(SequenceMethod method)
+ {
+ var original = CreateParameters();
+ original.Assignment.Method = method;
+ original.Sequencing.Method = method;
+
+ AssertEquivalent(original, RoundTrip(original));
+ }
+
+ [Fact]
+ public void Deserialize_LegacySettings_PreservesValuesAndUsesMissingFieldDefaults()
+ {
+ const string json = """
+ {
+ "externalLeadIn": { "type": "LineLine", "length1": 0.2, "angle1": 31,
+ "length2": 0.4, "angle2": 62 },
+ "externalLeadOut": { "type": "Line", "length": 0.5, "approachAngle": 43 },
+ "internalLeadIn": { "type": "Arc", "radius": 0.12 },
+ "internalLeadOut": { "type": "Arc", "radius": 0.09, "gapSize": 0 },
+ "arcCircleLeadIn": { "type": "CleanHole", "lineLength": 0.8,
+ "arcRadius": 0.3, "kerf": 0.04 },
+ "arcCircleLeadOut": { "type": "None" },
+ "tabsEnabled": true,
+ "tabWidth": 0.375,
+ "pierceClearance": 0.0625
+ }
+ """;
+
+ var restored = CuttingParametersSerializer.Deserialize(json);
+
+ var lineLine = Assert.IsType(restored.ExternalLeadIn);
+ Assert.Equal(0.2, lineLine.Length1);
+ Assert.Equal(31, lineLine.ApproachAngle1);
+ Assert.Equal(0.4, lineLine.Length2);
+ Assert.Equal(62, lineLine.ApproachAngle2);
+ var lineOut = Assert.IsType(restored.ExternalLeadOut);
+ Assert.Equal(0.5, lineOut.Length);
+ Assert.Equal(43, lineOut.ApproachAngle);
+ Assert.Equal(0.12, Assert.IsType(restored.InternalLeadIn).Radius);
+ Assert.Equal(0.09, Assert.IsType(restored.InternalLeadOut).Radius);
+ var cleanHole = Assert.IsType(restored.ArcCircleLeadIn);
+ Assert.Equal(0.8, cleanHole.LineLength);
+ Assert.Equal(0.3, cleanHole.ArcRadius);
+ Assert.Equal(0.04, cleanHole.Kerf);
+ Assert.IsType(restored.ArcCircleLeadOut);
+ Assert.True(restored.TabsEnabled);
+ Assert.Equal(0.375, Assert.IsType(restored.TabConfig).Size);
+ Assert.Equal(0.0625, restored.PierceClearance);
+ Assert.False(restored.RoundLeadInAngles);
+ Assert.Equal(5, restored.LeadInAngleIncrement);
+ Assert.Equal(0, restored.AutoTabMinSize);
+ Assert.Equal(0, restored.AutoTabMaxSize);
+ AssertEquivalent(new AssignmentParameters(), restored.Assignment);
+ AssertEquivalent(new SequenceParameters(), restored.Sequencing);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Deserialize_LegacyNonpositiveAngleIncrement_UsesFiveDegrees(double increment)
+ {
+ var json = JsonSerializer.Serialize(new { leadInAngleIncrement = increment });
+
+ Assert.Equal(5, CuttingParametersSerializer.Deserialize(json).LeadInAngleIncrement);
+ }
+
+ [Fact]
+ public void Deserialize_EmptyObject_PreservesLegacyDefaults()
+ {
+ var restored = CuttingParametersSerializer.Deserialize("{}");
+
+ Assert.IsType(restored.ExternalLeadIn);
+ Assert.IsType(restored.InternalLeadIn);
+ Assert.IsType(restored.ArcCircleLeadIn);
+ Assert.IsType(restored.ExternalLeadOut);
+ Assert.IsType(restored.InternalLeadOut);
+ Assert.IsType(restored.ArcCircleLeadOut);
+ Assert.Equal(0, Assert.IsType(restored.TabConfig).Size);
+ Assert.Equal(0, restored.PierceClearance);
+ Assert.Equal(5, restored.LeadInAngleIncrement);
+ }
+
+ [Fact]
+ public void Deserialize_JsonNull_ReturnsDomainDefaults()
+ {
+ AssertEquivalent(new CuttingParameters(), CuttingParametersSerializer.Deserialize("null"));
+ }
+
+ [Theory]
+ [InlineData("not-json")]
+ [InlineData("{\"externalLeadIn\":17}")]
+ public void Deserialize_MalformedSettings_ThrowsJsonException(string json)
+ {
+ Assert.Throws(() => CuttingParametersSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void Deserialize_UnknownLeadTypes_UsesNoLead()
+ {
+ var restored = CuttingParametersSerializer.Deserialize("""
+ { "externalLeadIn": { "type": "FutureLead" },
+ "externalLeadOut": { "type": "FutureLead" } }
+ """);
+
+ Assert.IsType(restored.ExternalLeadIn);
+ Assert.IsType(restored.ExternalLeadOut);
+ }
+
+ [Fact]
+ public void SettingsRoundTrip_NullTab_PreservesLegacyWidthFallback()
+ {
+ var original = new CuttingParameters { TabConfig = null };
+ var json = CuttingParametersSerializer.Serialize(original);
+ using var document = JsonDocument.Parse(json);
+
+ Assert.Equal(0.25, document.RootElement.GetProperty("tabWidth").GetDouble());
+ Assert.DoesNotContain('\n', json);
+ Assert.Equal(0.25, Assert.IsType(
+ CuttingParametersSerializer.Deserialize(json).TabConfig).Size);
+ }
+
+ [Theory]
+ [InlineData("Normal")]
+ [InlineData("Machine")]
+ [InlineData("Breaker")]
+ public void DtoRoundTrip_UsesNestJsonOptionsAndPreservesAllFields(string tabType)
+ {
+ var original = CreateParameters();
+ original.TabConfig = CreateTab(tabType);
+ original.LeadInAngleIncrement = 0;
+ var dto = CuttingParametersSerializer.ToDto(original);
+
+ var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
+ var parsed = JsonSerializer.Deserialize(json, NestFormat.JsonOptions);
+ var restored = CuttingParametersSerializer.FromDto(parsed);
+
+ AssertEquivalent(original, restored);
+ }
+
+ [Fact]
+ public void DtoRoundTrip_NullParameters_RemainNull()
+ {
+ Assert.Null(CuttingParametersSerializer.ToDto(null));
+ Assert.Null(CuttingParametersSerializer.FromDto(null));
+ }
+
+ [Fact]
+ public void DtoRoundTrip_DefaultParameters_PreservesNullTab()
+ {
+ var original = new CuttingParameters();
+ var dto = CuttingParametersSerializer.ToDto(original);
+ var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
+ var parsed = JsonSerializer.Deserialize(json, NestFormat.JsonOptions);
+
+ AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
+ }
+
+ [Fact]
+ public void DtoRoundTrip_NullAssignmentAndSequencing_RemainNull()
+ {
+ var original = new CuttingParameters { Assignment = null, Sequencing = null };
+ var dto = CuttingParametersSerializer.ToDto(original);
+ var json = JsonSerializer.Serialize(dto, NestFormat.JsonOptions);
+ var parsed = JsonSerializer.Deserialize(json, NestFormat.JsonOptions);
+
+ AssertEquivalent(original, CuttingParametersSerializer.FromDto(parsed));
+ }
+
+ [Fact]
+ public void DtoMapping_SourceSnapshotAndRestoredParameters_AreIndependent()
+ {
+ var original = CreateParameters();
+ var dto = CuttingParametersSerializer.ToDto(original);
+ var restored = CuttingParametersSerializer.FromDto(dto);
+ AssertEquivalent(original, restored);
+
+ original.Assignment.Preference = "changed source";
+ original.Sequencing.SmallCutoutWidth = 101;
+ ((LineLineLeadIn)original.ExternalLeadIn).Length1 = 102;
+ ((BreakerTab)original.TabConfig).BreakerDepth = 103;
+ ((LineArcLeadIn)original.TabConfig.TabLeadIn).ArcRadius = 104;
+ ((ArcLeadOut)original.TabConfig.TabLeadOut).Radius = 105;
+ AssertEquivalent(CreateParameters(), CuttingParametersSerializer.FromDto(dto));
+
+ dto.Assignment.Preference = "changed snapshot";
+ dto.Sequencing.SmallCutoutWidth = 201;
+ dto.ExternalLeadIn.Length1 = 202;
+ dto.TabConfig.BreakerDepth = 203;
+ dto.TabConfig.TabLeadIn.ArcRadius = 204;
+ dto.TabConfig.TabLeadOut.Radius = 205;
+ AssertEquivalent(CreateParameters(), restored);
+ }
+
+ [Fact]
+ public void Deserialize_UnknownTabType_DoesNotInventACutStrategy()
+ {
+ var restored = CuttingParametersSerializer.Deserialize("""
+ { "tabConfig": { "type": "FutureTab" }, "tabWidth": 0.7 }
+ """);
+
+ Assert.Null(restored.TabConfig);
+ }
+
+ private static CuttingParameters RoundTrip(CuttingParameters original) =>
+ CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(original));
+
+ private static CuttingParameters CreateParameters() => new()
+ {
+ Id = 47,
+ MachineName = "Laser A",
+ MaterialName = "Steel",
+ Grade = "A36",
+ Thickness = 0.1875,
+ Kerf = 0.018,
+ PartSpacing = 0.23,
+ ExternalLeadIn = CreateLeadIn("LineLine"),
+ ExternalLeadOut = CreateLeadOut("Line"),
+ InternalLeadIn = CreateLeadIn("CleanHole"),
+ InternalLeadOut = CreateLeadOut("Arc"),
+ ArcCircleLeadIn = CreateLeadIn("LineArc"),
+ ArcCircleLeadOut = CreateLeadOut("None"),
+ PierceClearance = 0.17,
+ RoundLeadInAngles = true,
+ LeadInAngleIncrement = 13,
+ AutoTabMinSize = 0.62,
+ AutoTabMaxSize = 4.7,
+ TabConfig = CreateTab("Breaker"),
+ TabsEnabled = true,
+ Assignment = new AssignmentParameters
+ {
+ Method = SequenceMethod.EdgeStart,
+ Preference = "TAIL",
+ MinGeometryLength = 0.37,
+ },
+ Sequencing = new SequenceParameters
+ {
+ Method = SequenceMethod.LeftSide,
+ SmallCutoutWidth = 2.3,
+ SmallCutoutHeight = 3.4,
+ MediumCutoutWidth = 9.5,
+ MediumCutoutHeight = 10.6,
+ DistanceMediumSmall = 1.7,
+ AlternateRowsColumns = false,
+ AlternateCutoutsWithinRowColumn = false,
+ MinDistanceBetweenRowsColumns = 0.8,
+ },
+ };
+
+ private static LeadIn CreateLeadIn(string type) => type switch
+ {
+ "Line" => new LineLeadIn { Length = 0.37, ApproachAngle = 47 },
+ "Arc" => new ArcLeadIn { Radius = 0.53 },
+ "LineArc" => new LineArcLeadIn
+ {
+ LineLength = 0.43,
+ ArcRadius = 0.29,
+ ApproachAngle = 118,
+ },
+ "CleanHole" => new CleanHoleLeadIn { LineLength = 0.38, ArcRadius = 0.16, Kerf = 0.021 },
+ "LineLine" => new LineLineLeadIn
+ {
+ Length1 = 0.24,
+ ApproachAngle1 = 72,
+ Length2 = 0.48,
+ ApproachAngle2 = 36,
+ },
+ "None" => new NoLeadIn(),
+ _ => throw new ArgumentOutOfRangeException(nameof(type)),
+ };
+
+ private static LeadOut CreateLeadOut(string type) => type switch
+ {
+ "Line" => new LineLeadOut { Length = 0.21, ApproachAngle = 58 },
+ "Arc" => new ArcLeadOut { Radius = 0.19 },
+ "None" => new NoLeadOut(),
+ _ => throw new ArgumentOutOfRangeException(nameof(type)),
+ };
+
+ private static Tab CreateTab(string type)
+ {
+ var tab = type switch
+ {
+ "Normal" => (Tab)new NormalTab
+ {
+ CutoutMinWidth = 0.31,
+ CutoutMinHeight = 0.52,
+ CutoutMaxWidth = 3.7,
+ CutoutMaxHeight = 4.6,
+ },
+ "Machine" => new MachineTab { MachineTabId = 29 },
+ "Breaker" => new BreakerTab
+ {
+ BreakerDepth = 0.013,
+ BreakerLeadInLength = 0.09,
+ BreakerAngle = 26,
+ },
+ _ => throw new ArgumentOutOfRangeException(nameof(type)),
+ };
+ tab.Size = 0.41;
+ tab.TabLeadIn = CreateLeadIn("LineArc");
+ tab.TabLeadOut = CreateLeadOut("Arc");
+ return tab;
+ }
+
+ // Compare concrete runtime properties, including nested subtype fields, rather than
+ // using the serializer under test as the equality oracle. All model objects must be owned.
+ private static void AssertEquivalent(object? expected, object? actual)
+ {
+ if (expected == null)
+ {
+ Assert.Null(actual);
+ return;
+ }
+
+ Assert.NotNull(actual);
+ var type = expected.GetType();
+ Assert.Equal(type, actual.GetType());
+ if (type.IsValueType || expected is string)
+ {
+ Assert.Equal(expected, actual);
+ return;
+ }
+
+ Assert.NotSame(expected, actual);
+ foreach (var property in type.GetProperties())
+ AssertEquivalent(property.GetValue(expected), property.GetValue(actual));
+ }
+}
diff --git a/OpenNest.IO/CuttingParametersDto.cs b/OpenNest.IO/CuttingParametersDto.cs
new file mode 100644
index 0000000..c52e2bb
--- /dev/null
+++ b/OpenNest.IO/CuttingParametersDto.cs
@@ -0,0 +1,97 @@
+using OpenNest.CNC.CuttingStrategy;
+
+namespace OpenNest.IO;
+
+///
+/// JSON-safe cutting parameters shared by nest files and desktop settings.
+/// Lead and tab discriminators avoid serializing abstract domain types.
+///
+public class CuttingParametersDto
+{
+ public int Id { get; set; }
+ public string MachineName { get; set; }
+ public string MaterialName { get; set; }
+ public string Grade { get; set; }
+ public double Thickness { get; set; }
+ public double Kerf { get; set; }
+ public double PartSpacing { get; set; }
+ public LeadInDto ExternalLeadIn { get; set; }
+ public LeadOutDto ExternalLeadOut { get; set; }
+ public LeadInDto InternalLeadIn { get; set; }
+ public LeadOutDto InternalLeadOut { get; set; }
+ public LeadInDto ArcCircleLeadIn { get; set; }
+ public LeadOutDto ArcCircleLeadOut { get; set; }
+ public bool TabsEnabled { get; set; }
+
+ // Legacy settings only stored tabWidth. A missing TabConfig uses that width;
+ // Type = "None" explicitly records a null tab in a nest parameter snapshot.
+ public double TabWidth { get; set; }
+ public TabDto TabConfig { get; set; }
+ public double PierceClearance { get; set; }
+ public bool RoundLeadInAngles { get; set; }
+ public double LeadInAngleIncrement { get; set; }
+ public double AutoTabMinSize { get; set; }
+ public double AutoTabMaxSize { get; set; }
+ public SequenceDto Sequencing { get; set; } = new();
+ public AssignmentDto Assignment { get; set; } = new();
+
+ public class LeadInDto
+ {
+ public string Type { get; set; } = "None";
+ public double Length { get; set; }
+ public double ApproachAngle { get; set; }
+ public double Radius { get; set; }
+ public double LineLength { get; set; }
+ public double ArcRadius { get; set; }
+ public double Kerf { get; set; }
+ public double Length1 { get; set; }
+ public double Angle1 { get; set; }
+ public double Length2 { get; set; }
+ public double Angle2 { get; set; }
+ }
+
+ public class LeadOutDto
+ {
+ public string Type { get; set; } = "None";
+ public double Length { get; set; }
+ public double ApproachAngle { get; set; }
+ public double Radius { get; set; }
+ public double GapSize { get; set; }
+ }
+
+ public class TabDto
+ {
+ public string Type { get; set; } = "None";
+ public double Size { get; set; }
+ public LeadInDto TabLeadIn { get; set; }
+ public LeadOutDto TabLeadOut { get; set; }
+ public double CutoutMinWidth { get; set; }
+ public double CutoutMinHeight { get; set; }
+ public double CutoutMaxWidth { get; set; }
+ public double CutoutMaxHeight { get; set; }
+ public int MachineTabId { get; set; }
+ public double BreakerDepth { get; set; }
+ public double BreakerLeadInLength { get; set; }
+ public double BreakerAngle { get; set; }
+ }
+
+ public class SequenceDto
+ {
+ public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
+ public double SmallCutoutWidth { get; set; } = 1.5;
+ public double SmallCutoutHeight { get; set; } = 1.5;
+ public double MediumCutoutWidth { get; set; } = 8.0;
+ public double MediumCutoutHeight { get; set; } = 8.0;
+ public double DistanceMediumSmall { get; set; }
+ public bool AlternateRowsColumns { get; set; } = true;
+ public bool AlternateCutoutsWithinRowColumn { get; set; } = true;
+ public double MinDistanceBetweenRowsColumns { get; set; } = 0.25;
+ }
+
+ public class AssignmentDto
+ {
+ public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
+ public string Preference { get; set; } = "ILAT";
+ public double MinGeometryLength { get; set; } = 0.01;
+ }
+}
diff --git a/OpenNest.IO/CuttingParametersSerializer.cs b/OpenNest.IO/CuttingParametersSerializer.cs
new file mode 100644
index 0000000..9b54f46
--- /dev/null
+++ b/OpenNest.IO/CuttingParametersSerializer.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Text.Json;
+using OpenNest.CNC.CuttingStrategy;
+using static OpenNest.IO.CuttingParametersDto;
+
+namespace OpenNest.IO;
+
+public static class CuttingParametersSerializer
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ /// Writes desktop settings, retaining the legacy null-tab width fallback.
+ public static string Serialize(CuttingParameters parameters)
+ {
+ ArgumentNullException.ThrowIfNull(parameters);
+ var dto = ToDto(parameters);
+ if (parameters.TabConfig == null)
+ dto.TabConfig = null;
+ return JsonSerializer.Serialize(dto, JsonOptions);
+ }
+
+ /// Reads desktop settings with their historical defaults and angle fallback.
+ public static CuttingParameters Deserialize(string json)
+ {
+ var dto = JsonSerializer.Deserialize(json, JsonOptions);
+ var parameters = FromDto(dto) ?? new CuttingParameters();
+ if (parameters.LeadInAngleIncrement <= 0)
+ parameters.LeadInAngleIncrement = 5.0;
+ return parameters;
+ }
+
+ /// Captures an owned snapshot. Null stays null for absent plate parameters.
+ public static CuttingParametersDto ToDto(CuttingParameters parameters)
+ {
+ if (parameters == null)
+ return null;
+
+ return new CuttingParametersDto
+ {
+ Id = parameters.Id,
+ MachineName = parameters.MachineName,
+ MaterialName = parameters.MaterialName,
+ Grade = parameters.Grade,
+ Thickness = parameters.Thickness,
+ Kerf = parameters.Kerf,
+ PartSpacing = parameters.PartSpacing,
+ ExternalLeadIn = ToLeadInDto(parameters.ExternalLeadIn),
+ ExternalLeadOut = ToLeadOutDto(parameters.ExternalLeadOut),
+ InternalLeadIn = ToLeadInDto(parameters.InternalLeadIn),
+ InternalLeadOut = ToLeadOutDto(parameters.InternalLeadOut),
+ ArcCircleLeadIn = ToLeadInDto(parameters.ArcCircleLeadIn),
+ ArcCircleLeadOut = ToLeadOutDto(parameters.ArcCircleLeadOut),
+ TabsEnabled = parameters.TabsEnabled,
+ TabWidth = parameters.TabConfig?.Size ?? 0.25,
+ TabConfig = ToTabDto(parameters.TabConfig),
+ PierceClearance = parameters.PierceClearance,
+ RoundLeadInAngles = parameters.RoundLeadInAngles,
+ LeadInAngleIncrement = parameters.LeadInAngleIncrement,
+ AutoTabMinSize = parameters.AutoTabMinSize,
+ AutoTabMaxSize = parameters.AutoTabMaxSize,
+ Sequencing = ToSequenceDto(parameters.Sequencing),
+ Assignment = ToAssignmentDto(parameters.Assignment),
+ };
+ }
+
+ /// Restores an owned snapshot without the desktop settings' value normalization.
+ public static CuttingParameters FromDto(CuttingParametersDto dto)
+ {
+ if (dto == null)
+ return null;
+
+ return new CuttingParameters
+ {
+ Id = dto.Id,
+ MachineName = dto.MachineName,
+ MaterialName = dto.MaterialName,
+ Grade = dto.Grade,
+ Thickness = dto.Thickness,
+ Kerf = dto.Kerf,
+ PartSpacing = dto.PartSpacing,
+ ExternalLeadIn = FromLeadInDto(dto.ExternalLeadIn),
+ ExternalLeadOut = FromLeadOutDto(dto.ExternalLeadOut),
+ InternalLeadIn = FromLeadInDto(dto.InternalLeadIn),
+ InternalLeadOut = FromLeadOutDto(dto.InternalLeadOut),
+ ArcCircleLeadIn = FromLeadInDto(dto.ArcCircleLeadIn),
+ ArcCircleLeadOut = FromLeadOutDto(dto.ArcCircleLeadOut),
+ TabsEnabled = dto.TabsEnabled,
+ TabConfig = dto.TabConfig == null
+ ? new NormalTab { Size = dto.TabWidth }
+ : FromTabDto(dto.TabConfig),
+ PierceClearance = dto.PierceClearance,
+ RoundLeadInAngles = dto.RoundLeadInAngles,
+ LeadInAngleIncrement = dto.LeadInAngleIncrement,
+ AutoTabMinSize = dto.AutoTabMinSize,
+ AutoTabMaxSize = dto.AutoTabMaxSize,
+ Sequencing = FromSequenceDto(dto.Sequencing),
+ Assignment = FromAssignmentDto(dto.Assignment),
+ };
+ }
+
+ private static LeadInDto ToLeadInDto(LeadIn leadIn) => leadIn switch
+ {
+ LineLeadIn line => new LeadInDto
+ {
+ Type = "Line",
+ Length = line.Length,
+ ApproachAngle = line.ApproachAngle,
+ },
+ ArcLeadIn arc => new LeadInDto { Type = "Arc", Radius = arc.Radius },
+ LineArcLeadIn lineArc => new LeadInDto
+ {
+ Type = "LineArc",
+ LineLength = lineArc.LineLength,
+ ArcRadius = lineArc.ArcRadius,
+ ApproachAngle = lineArc.ApproachAngle,
+ },
+ CleanHoleLeadIn cleanHole => new LeadInDto
+ {
+ Type = "CleanHole",
+ LineLength = cleanHole.LineLength,
+ ArcRadius = cleanHole.ArcRadius,
+ Kerf = cleanHole.Kerf,
+ },
+ LineLineLeadIn lineLine => new LeadInDto
+ {
+ Type = "LineLine",
+ Length1 = lineLine.Length1,
+ Angle1 = lineLine.ApproachAngle1,
+ Length2 = lineLine.Length2,
+ Angle2 = lineLine.ApproachAngle2,
+ },
+ _ => new LeadInDto { Type = "None" },
+ };
+
+ private static LeadIn FromLeadInDto(LeadInDto dto) => dto?.Type switch
+ {
+ "Line" => new LineLeadIn { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
+ "Arc" => new ArcLeadIn { Radius = dto.Radius },
+ "LineArc" => new LineArcLeadIn
+ {
+ LineLength = dto.LineLength,
+ ArcRadius = dto.ArcRadius,
+ ApproachAngle = dto.ApproachAngle,
+ },
+ "CleanHole" => new CleanHoleLeadIn
+ {
+ LineLength = dto.LineLength,
+ ArcRadius = dto.ArcRadius,
+ Kerf = dto.Kerf,
+ },
+ "LineLine" => new LineLineLeadIn
+ {
+ Length1 = dto.Length1,
+ ApproachAngle1 = dto.Angle1,
+ Length2 = dto.Length2,
+ ApproachAngle2 = dto.Angle2,
+ },
+ _ => new NoLeadIn(),
+ };
+
+ private static LeadOutDto ToLeadOutDto(LeadOut leadOut) => leadOut switch
+ {
+ LineLeadOut line => new LeadOutDto
+ {
+ Type = "Line",
+ Length = line.Length,
+ ApproachAngle = line.ApproachAngle,
+ },
+ ArcLeadOut arc => new LeadOutDto { Type = "Arc", Radius = arc.Radius },
+ _ => new LeadOutDto { Type = "None" },
+ };
+
+ private static LeadOut FromLeadOutDto(LeadOutDto dto) => dto?.Type switch
+ {
+ "Line" => new LineLeadOut { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
+ "Arc" => new ArcLeadOut { Radius = dto.Radius },
+ _ => new NoLeadOut(),
+ };
+
+ private static TabDto ToTabDto(Tab tab)
+ {
+ var dto = tab switch
+ {
+ NormalTab normal => new TabDto
+ {
+ Type = "Normal",
+ CutoutMinWidth = normal.CutoutMinWidth,
+ CutoutMinHeight = normal.CutoutMinHeight,
+ CutoutMaxWidth = normal.CutoutMaxWidth,
+ CutoutMaxHeight = normal.CutoutMaxHeight,
+ },
+ MachineTab machine => new TabDto { Type = "Machine", MachineTabId = machine.MachineTabId },
+ BreakerTab breaker => new TabDto
+ {
+ Type = "Breaker",
+ BreakerDepth = breaker.BreakerDepth,
+ BreakerLeadInLength = breaker.BreakerLeadInLength,
+ BreakerAngle = breaker.BreakerAngle,
+ },
+ _ => new TabDto { Type = "None" },
+ };
+ if (tab != null)
+ {
+ dto.Size = tab.Size;
+ dto.TabLeadIn = tab.TabLeadIn == null ? null : ToLeadInDto(tab.TabLeadIn);
+ dto.TabLeadOut = tab.TabLeadOut == null ? null : ToLeadOutDto(tab.TabLeadOut);
+ }
+ return dto;
+ }
+
+ private static Tab FromTabDto(TabDto dto)
+ {
+ var tab = dto.Type switch
+ {
+ "Normal" => (Tab)new NormalTab
+ {
+ CutoutMinWidth = dto.CutoutMinWidth,
+ CutoutMinHeight = dto.CutoutMinHeight,
+ CutoutMaxWidth = dto.CutoutMaxWidth,
+ CutoutMaxHeight = dto.CutoutMaxHeight,
+ },
+ "Machine" => new MachineTab { MachineTabId = dto.MachineTabId },
+ "Breaker" => new BreakerTab
+ {
+ BreakerDepth = dto.BreakerDepth,
+ BreakerLeadInLength = dto.BreakerLeadInLength,
+ BreakerAngle = dto.BreakerAngle,
+ },
+ _ => null,
+ };
+ if (tab != null)
+ {
+ tab.Size = dto.Size;
+ tab.TabLeadIn = dto.TabLeadIn == null ? null : FromLeadInDto(dto.TabLeadIn);
+ tab.TabLeadOut = dto.TabLeadOut == null ? null : FromLeadOutDto(dto.TabLeadOut);
+ }
+ return tab;
+ }
+
+ private static SequenceDto ToSequenceDto(SequenceParameters parameters) => parameters == null
+ ? null
+ : new SequenceDto
+ {
+ Method = parameters.Method,
+ SmallCutoutWidth = parameters.SmallCutoutWidth,
+ SmallCutoutHeight = parameters.SmallCutoutHeight,
+ MediumCutoutWidth = parameters.MediumCutoutWidth,
+ MediumCutoutHeight = parameters.MediumCutoutHeight,
+ DistanceMediumSmall = parameters.DistanceMediumSmall,
+ AlternateRowsColumns = parameters.AlternateRowsColumns,
+ AlternateCutoutsWithinRowColumn = parameters.AlternateCutoutsWithinRowColumn,
+ MinDistanceBetweenRowsColumns = parameters.MinDistanceBetweenRowsColumns,
+ };
+
+ private static SequenceParameters FromSequenceDto(SequenceDto dto) => dto == null
+ ? null
+ : new SequenceParameters
+ {
+ Method = dto.Method,
+ SmallCutoutWidth = dto.SmallCutoutWidth,
+ SmallCutoutHeight = dto.SmallCutoutHeight,
+ MediumCutoutWidth = dto.MediumCutoutWidth,
+ MediumCutoutHeight = dto.MediumCutoutHeight,
+ DistanceMediumSmall = dto.DistanceMediumSmall,
+ AlternateRowsColumns = dto.AlternateRowsColumns,
+ AlternateCutoutsWithinRowColumn = dto.AlternateCutoutsWithinRowColumn,
+ MinDistanceBetweenRowsColumns = dto.MinDistanceBetweenRowsColumns,
+ };
+
+ private static AssignmentDto ToAssignmentDto(AssignmentParameters parameters) => parameters == null
+ ? null
+ : new AssignmentDto
+ {
+ Method = parameters.Method,
+ Preference = parameters.Preference,
+ MinGeometryLength = parameters.MinGeometryLength,
+ };
+
+ private static AssignmentParameters FromAssignmentDto(AssignmentDto dto) => dto == null
+ ? null
+ : new AssignmentParameters
+ {
+ Method = dto.Method,
+ Preference = dto.Preference,
+ MinGeometryLength = dto.MinGeometryLength,
+ };
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index fd4a30e..d73cba6 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -66,6 +66,7 @@ namespace OpenNest.IO
public double PartSpacing { get; init; }
public SpacingDto EdgeSpacing { get; init; } = new();
public double GrainAngle { get; init; }
+ public CuttingParametersDto CuttingParameters { get; init; }
public List Parts { get; init; } = new();
public List CutOffs { get; init; } = new();
}
@@ -78,6 +79,8 @@ namespace OpenNest.IO
public double Rotation { get; init; }
public bool HasManualLeadIns { get; init; }
public bool LeadInsLocked { get; init; }
+ public string Program { get; init; }
+ public string DrawingHash { get; init; }
}
public record CutOffDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index dc7174d..bd9c072 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -18,6 +18,11 @@ namespace OpenNest.IO
{
private readonly Stream stream;
private readonly ZipArchive zipArchive;
+ private readonly Dictionary drawingHashes = new();
+ private readonly List warnings = new();
+
+ /// Part programs that could not be restored; other parts still load.
+ public IReadOnlyList Warnings => warnings.AsReadOnly();
public NestReader(string file)
{
@@ -63,29 +68,59 @@ namespace OpenNest.IO
var programs = new Dictionary();
for (var i = 1; i <= count; i++)
{
- var entry = zipArchive.GetEntry($"programs/program-{i}");
- if (entry == null)
+ var name = $"programs/program-{i}";
+ if (zipArchive.GetEntry(name) == null)
continue;
-
- using var entryStream = entry.Open();
- var memStream = new MemoryStream();
- entryStream.CopyTo(memStream);
- memStream.Position = 0;
-
- var reader = new ProgramReader(memStream);
- programs[i] = reader.Read();
-
- // Read sub-programs if present
- var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
- if (subsEntry != null)
- {
- using var subsStream = subsEntry.Open();
- ReadSubPrograms(programs[i], subsStream);
- }
+ programs[i] = ReadProgram(name, out var hash);
+ drawingHashes[i] = hash;
}
return programs;
}
+ private Program ReadProgram(string name, out string hash)
+ {
+ var text = ReadEntry(name);
+ var subs = zipArchive.GetEntry(name + "-subs") == null ? "" : ReadEntry(name + "-subs");
+ hash = NestWriter.GetDrawingHash(text, subs);
+ using var programStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
+ var program = new ProgramReader(programStream).Read();
+ if (subs.Length > 0)
+ {
+ using var subsStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(subs));
+ ReadSubPrograms(program, subsStream);
+ }
+ return program;
+ }
+
+ private void RestorePartProgram(Part part, PartDto dto, int plateId, int partIndex)
+ {
+ // Old files had only transient flags. They still load clean and silently.
+ if (string.IsNullOrEmpty(dto.Program))
+ return;
+
+ // A missing hash cannot establish that this program belongs to the current drawing.
+ if (!drawingHashes.TryGetValue(dto.DrawingId, out var hash)
+ || !string.Equals(dto.DrawingHash, hash, StringComparison.Ordinal))
+ return;
+
+ try
+ {
+ var program = ReadProgram(dto.Program, out _);
+ foreach (var call in program.Codes.OfType())
+ if (call.Program == null || !call.Program.Codes.Any(c => c is Motion))
+ throw new InvalidDataException($"Missing or empty hole sub-program {call.Id}.");
+
+ if (!part.RestoreLeadInProgram(program, dto.LeadInsLocked))
+ throw new InvalidDataException("The saved part program has no motion.");
+ }
+ catch (Exception ex) when (ex is IOException || ex is InvalidDataException || ex is FormatException
+ || ex is OverflowException || ex is ArgumentException || ex is InvalidOperationException)
+ {
+ warnings.Add($"Plate {plateId}, part {partIndex} ('{part.BaseDrawing.Name}'): "
+ + $"could not restore '{dto.Program}'; loaded the clean drawing without lead-ins or tabs. {ex.Message}");
+ }
+ }
+
private static void ReadSubPrograms(Program parent, Stream stream)
{
using var reader = new StreamReader(stream);
@@ -357,15 +392,18 @@ namespace OpenNest.IO
p.EdgeSpacing.Top
);
plate.GrainAngle = p.GrainAngle;
+ plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
- foreach (var partDto in p.Parts)
+ for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{
+ var partDto = p.Parts[partIndex];
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
continue;
var part = new Part(dwg);
part.Rotate(partDto.Rotation);
part.Offset(new Vector(partDto.X, partDto.Y));
+ RestorePartProgram(part, partDto, p.Id, partIndex);
plate.Parts.Add(part);
}
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index eac2d73..2760de9 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using OpenNest.CNC;
@@ -18,6 +20,8 @@ namespace OpenNest.IO
private readonly Nest nest;
private Dictionary drawingDict;
+ private readonly Dictionary drawingHashes = new();
+ private readonly Dictionary partPrograms = new();
public NestWriter(Nest nest)
{
@@ -38,8 +42,12 @@ namespace OpenNest.IO
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
- WriteNestJson(zipArchive);
+ drawingHashes.Clear();
+ partPrograms.Clear();
WritePrograms(zipArchive);
+ WriteNestJson(zipArchive);
+ foreach (var entry in partPrograms)
+ WriteProgramEntry(zipArchive, entry.Key, entry.Value);
WriteEntities(zipArchive);
WriteBestFits(zipArchive);
@@ -48,6 +56,7 @@ namespace OpenNest.IO
private void SetDrawingIds()
{
+ drawingDict.Clear();
var id = 1;
foreach (var drawing in nest.Drawings)
{
@@ -207,6 +216,11 @@ namespace OpenNest.IO
var match = drawingDict
.Where(dwg => dwg.Value == part.BaseDrawing)
.FirstOrDefault();
+ var programName = part.HasManualLeadIns
+ ? $"parts/plate-{id}/part-{parts.Count}"
+ : null;
+ if (programName != null)
+ partPrograms.Add(programName, part.Program);
parts.Add(
new PartDto
{
@@ -216,6 +230,8 @@ namespace OpenNest.IO
Rotation = part.Rotation,
HasManualLeadIns = part.HasManualLeadIns,
LeadInsLocked = part.LeadInsLocked,
+ Program = programName,
+ DrawingHash = programName == null ? null : drawingHashes[match.Key],
}
);
}
@@ -264,6 +280,7 @@ namespace OpenNest.IO
Parts = parts,
CutOffs = cutoffs,
GrainAngle = plate.GrainAngle,
+ CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
}
);
}
@@ -342,43 +359,44 @@ namespace OpenNest.IO
private void WritePrograms(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
- {
- var name = $"programs/program-{kvp.Key}";
- var stream = new MemoryStream();
- WriteDrawing(stream, kvp.Value);
-
- var entry = zipArchive.CreateEntry(name);
- using (var entryStream = entry.Open())
- {
- stream.CopyTo(entryStream);
- }
-
- // Write sub-programs if present
- if (kvp.Value.Program.SubPrograms.Count > 0)
- WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
- }
+ drawingHashes[kvp.Key] = WriteProgramEntry(
+ zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
}
- private void WriteSubPrograms(
- ZipArchive zipArchive,
- int drawingId,
- Dictionary subPrograms
- )
+ private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
{
- var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
- using var entryStream = entry.Open();
- using var writer = new StreamWriter(entryStream, Encoding.UTF8);
+ var text = GetProgramText(program);
+ var subs = GetSubProgramsText(program);
+ WriteTextEntry(zipArchive, name, text);
+ if (subs.Length > 0)
+ WriteTextEntry(zipArchive, name + "-subs", subs);
+ return GetDrawingHash(text, subs);
+ }
- foreach (var kvp in subPrograms.OrderBy(k => k.Key))
+ private static void WriteTextEntry(ZipArchive zipArchive, string name, string text)
+ {
+ var entry = zipArchive.CreateEntry(name);
+ using var stream = entry.Open();
+ using var writer = new StreamWriter(stream, new UTF8Encoding(false));
+ writer.Write(text);
+ }
+
+ // Hash the exact saved text, including holes: a change to either invalidates the part.
+ internal static string GetDrawingHash(string text, string subs) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
+ text.Length.ToString(CultureInfo.InvariantCulture) + ":" + text + subs)));
+
+ /// Serializes the hole programs using the same text as the nest archive.
+ public static string GetSubProgramsText(Program program)
+ {
+ using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
+ foreach (var kvp in program.SubPrograms.OrderBy(k => k.Key))
{
writer.WriteLine($":{kvp.Key}");
- writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91");
-
- foreach (var code in kvp.Value.Codes)
- writer.WriteLine(GetCodeString(code));
-
+ WriteProgram(writer, kvp.Value);
writer.WriteLine("M99");
}
+ return writer.ToString();
}
private void WriteEntities(ZipArchive zipArchive)
@@ -402,11 +420,16 @@ namespace OpenNest.IO
}
}
- private void WriteDrawing(Stream stream, Drawing drawing)
+ /// Serializes a program in its current local frame, without transforming it.
+ public static string GetProgramText(Program program)
+ {
+ using var writer = new StringWriter(CultureInfo.InvariantCulture) { NewLine = "\n" };
+ WriteProgram(writer, program);
+ return writer.ToString();
+ }
+
+ private static void WriteProgram(TextWriter writer, Program program)
{
- var program = drawing.Program;
- var writer = new StreamWriter(stream);
- writer.AutoFlush = true;
// Emit variable definitions before G-code
foreach (var v in program.Variables.Values)
@@ -421,16 +444,11 @@ namespace OpenNest.IO
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
- for (var i = 0; i < drawing.Program.Length; ++i)
- {
- var code = drawing.Program[i];
+ foreach (var code in program.Codes)
writer.WriteLine(GetCodeString(code));
- }
-
- stream.Position = 0;
}
- private string FormatCoord(
+ private static string FormatCoord(
double value,
string axis,
Dictionary variableRefs
@@ -441,7 +459,7 @@ namespace OpenNest.IO
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
}
- private string GetCodeString(ICode code)
+ private static string GetCodeString(ICode code)
{
switch (code.Type)
{
@@ -545,7 +563,7 @@ namespace OpenNest.IO
return string.Empty;
}
- private string GetLayerString(LayerType layer)
+ private static string GetLayerString(LayerType layer)
{
switch (layer)
{
diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs
index a39fb49..66ac9b6 100644
--- a/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs
+++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs
@@ -96,6 +96,24 @@ public class CIFiberSampleRegressionTests
return result;
}
+ [SkippableFact]
+ public void Post_ReconstructedNest_IsIdenticalAfterSavedLeadInsReload()
+ {
+ var fixture = ResolveFixture();
+ Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
+ var nest = LoadAndLeadIn(fixture.Value.Nest);
+ var expected = PostNest(nest);
+ using var stream = new MemoryStream();
+ new NestWriter(nest).Write(stream);
+ stream.Position = 0;
+ var reader = new NestReader(stream);
+ var restored = reader.Read();
+
+ Assert.Empty(reader.Warnings);
+ Assert.All(restored.Plates.SelectMany(plate => plate.Parts), part => Assert.True(part.HasManualLeadIns));
+ Assert.Equal(expected, PostNest(restored));
+ }
+
[SkippableFact]
public void Post_ReconstructedNest_MatchesSampleCounts()
{
diff --git a/OpenNest.Tests/DrawingProgramSnapshotTests.cs b/OpenNest.Tests/DrawingProgramSnapshotTests.cs
new file mode 100644
index 0000000..96d7870
--- /dev/null
+++ b/OpenNest.Tests/DrawingProgramSnapshotTests.cs
@@ -0,0 +1,254 @@
+using System.Drawing;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class DrawingProgramSnapshotTests
+{
+ private const double QuarterTurn = System.Math.PI / 2;
+
+ [Fact]
+ public void EquivalentReplacement_KeepsPlacedProgramAndLeadInState()
+ {
+ var drawing = MakeDrawing("unchanged");
+ var part = MakePart(drawing, withLeadIns: true);
+ var program = part.Program;
+ var parameters = part.CuttingParameters;
+ var bounds = part.BoundingBox;
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { drawing }, Fingerprint);
+
+ drawing.Program = (Program)drawing.Program.Clone();
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(part) });
+
+ Assert.Empty(updated);
+ Assert.Same(program, part.Program);
+ Assert.Same(bounds, part.BoundingBox);
+ Assert.Same(parameters, part.CuttingParameters);
+ Assert.True(part.HasManualLeadIns);
+ Assert.True(part.LeadInsLocked);
+ Assert.Equal(QuarterTurn, part.Rotation, 6);
+ Assert.Equal(new Vector(20, 30), part.Location);
+ }
+
+ [Fact]
+ public void MetadataEdits_KeepProgramsAndLeadInsAcrossPlates()
+ {
+ var drawing = MakeDrawing("before rename");
+ var first = MakePart(drawing, withLeadIns: true);
+ var second = first.CloneAtOffset(new Vector(15, 0));
+ var program = first.Program;
+ var parameters = first.CuttingParameters;
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { drawing }, Fingerprint);
+
+ drawing.Name = "after rename";
+ drawing.Quantity.Required = 42;
+ drawing.Color = Color.CornflowerBlue;
+ drawing.Customer = "New customer";
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(first), MakePlate(second) });
+
+ Assert.Empty(updated);
+ foreach (var part in new[] { first, second })
+ {
+ Assert.Same(program, part.Program);
+ Assert.Same(parameters, part.CuttingParameters);
+ Assert.True(part.HasManualLeadIns);
+ Assert.True(part.LeadInsLocked);
+ Assert.Equal(QuarterTurn, part.Rotation, 6);
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void InPlaceEdit_UpdatesOnlyMatchingDrawingReferencesAndPreservesPose(bool withLeadIns)
+ {
+ // Drawing.Equals/GetHashCode use the editable name, not reference identity.
+ var changedDrawing = MakeDrawing("same name");
+ var unchangedDrawing = MakeDrawing("same name");
+ var first = MakePart(changedDrawing, withLeadIns);
+ var second = first.CloneAtOffset(new Vector(15, 5));
+ var unchanged = MakePart(unchangedDrawing, withLeadIns: true);
+ var unchangedProgram = unchanged.Program;
+ var unchangedParameters = unchanged.CuttingParameters;
+ var originalProgram = first.Program;
+ var snapshot = DrawingProgramSnapshot.Capture(
+ new[] { changedDrawing, unchangedDrawing }, Fingerprint);
+
+ changedDrawing.Name = "renamed during edit";
+ EnlargeRectangle(changedDrawing.Program);
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(first, unchanged), MakePlate(second) });
+
+ Assert.Equal(new[] { first, second }, updated);
+ Assert.NotSame(originalProgram, first.Program);
+ Assert.NotSame(originalProgram, second.Program);
+ AssertCleanAtCurrentPose(first, new Vector(20, 30), QuarterTurn);
+ AssertCleanAtCurrentPose(second, new Vector(35, 35), QuarterTurn);
+ Assert.Same(unchangedProgram, unchanged.Program);
+ Assert.Same(unchangedParameters, unchanged.CuttingParameters);
+ Assert.True(unchanged.HasManualLeadIns);
+ Assert.True(unchanged.LeadInsLocked);
+ }
+
+ [Fact]
+ public void HoleOnlyEdit_UpdatesPartWhenMainProgramTextIsUnchanged()
+ {
+ var drawing = MakeDrawing("hole edit", withHole: true);
+ var part = MakePart(drawing, withLeadIns: true);
+ var mainText = drawing.Program.ToString();
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { drawing }, Fingerprint);
+
+ var hole = drawing.Program.SubPrograms[7];
+ // The incremental circle's centre changes without replacing either program.
+ var arc = Assert.IsType(hole.Codes[1]);
+ arc.CenterPoint = new Vector(-1.5, 0);
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(part) });
+
+ Assert.Equal(mainText, drawing.Program.ToString());
+ Assert.Same(part, Assert.Single(updated));
+ AssertCleanAtCurrentPose(part, new Vector(20, 30), QuarterTurn);
+ }
+
+ [Fact]
+ public void SharedDrawingProgram_InPlaceEditUpdatesBothDrawingsParts()
+ {
+ var firstDrawing = MakeDrawing("first");
+ var secondDrawing = new Drawing("second", firstDrawing.Program);
+ var first = MakePart(firstDrawing, withLeadIns: true);
+ var second = MakePart(secondDrawing, withLeadIns: true);
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { firstDrawing, secondDrawing }, Fingerprint);
+
+ EnlargeRectangle(firstDrawing.Program);
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(first, second) });
+
+ Assert.Equal(new[] { first, second }, updated);
+ AssertCleanAtCurrentPose(first, new Vector(20, 30), QuarterTurn);
+ AssertCleanAtCurrentPose(second, new Vector(20, 30), QuarterTurn);
+ }
+
+ [Fact]
+ public void CutOffAndUncapturedDrawings_KeepTheirPlacedPrograms()
+ {
+ var captured = MakeDrawing("same name");
+ var uncaptured = MakeDrawing("same name");
+ var cutOff = MakeDrawing("cutoff");
+ cutOff.IsCutOff = true;
+ var uncapturedPart = MakePart(uncaptured, withLeadIns: true);
+ var cutOffPart = new Part(cutOff);
+ var uncapturedProgram = uncapturedPart.Program;
+ var cutOffProgram = cutOffPart.Program;
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { captured, cutOff }, Fingerprint);
+
+ EnlargeRectangle(captured.Program);
+ EnlargeRectangle(uncaptured.Program);
+ EnlargeRectangle(cutOff.Program);
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(uncapturedPart, cutOffPart) });
+
+ Assert.Empty(updated);
+ Assert.Same(uncapturedProgram, uncapturedPart.Program);
+ Assert.True(uncapturedPart.HasManualLeadIns);
+ Assert.True(uncapturedPart.LeadInsLocked);
+ Assert.Same(cutOffProgram, cutOffPart.Program);
+ }
+
+ [Fact]
+ public void Capture_EagerlyFingerprintsEachDrawingOnceAndDoesNotRecheckPerPart()
+ {
+ var drawing = MakeDrawing("many parts");
+ var first = MakePart(drawing, withLeadIns: false);
+ var second = first.CloneAtOffset(new Vector(15, 0));
+ var calls = 0;
+ var snapshot = DrawingProgramSnapshot.Capture(new[] { drawing, drawing }, program =>
+ {
+ calls++;
+ return Fingerprint(program);
+ });
+ Assert.Equal(1, calls);
+
+ EnlargeRectangle(drawing.Program);
+ var updated = snapshot.UpdateChangedParts(new[] { MakePlate(first, second) });
+
+ Assert.Equal(2, calls);
+ Assert.Equal(new[] { first, second }, updated);
+ }
+
+ private static Drawing MakeDrawing(string name, bool withHole = false)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(new Vector(0, 0)));
+ program.Codes.Add(new LinearMove(new Vector(0, 10)));
+ program.Codes.Add(new LinearMove(new Vector(10, 10)));
+ program.Codes.Add(new LinearMove(new Vector(10, 0)));
+ program.Codes.Add(new LinearMove(new Vector(0, 0)));
+
+ if (withHole)
+ {
+ var hole = new Program();
+ hole.Codes.Add(new RapidMove(new Vector(1, 0)));
+ hole.Codes.Add(new ArcMove(new Vector(1, 0), Vector.Zero, RotationType.CW));
+ hole.Mode = Mode.Incremental;
+ program.SubPrograms[7] = hole;
+ program.Codes.Add(new SubProgramCall { Id = 7, Program = hole, Offset = new Vector(5, 5) });
+ }
+
+ return new Drawing(name, program);
+ }
+
+ private static Part MakePart(Drawing drawing, bool withLeadIns)
+ {
+ var part = new Part(drawing);
+ part.Rotate(QuarterTurn);
+ part.Location = new Vector(20, 30);
+ if (withLeadIns)
+ {
+ part.ApplyLeadIns(new CuttingParameters
+ {
+ ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
+ }, new Vector(-5, -5));
+ part.LeadInsLocked = true;
+ Assert.Contains(part.Program.Codes.OfType(), move => move.Layer == LayerType.Leadin);
+ }
+
+ return part;
+ }
+
+ private static Plate MakePlate(params Part[] parts)
+ {
+ var plate = new Plate();
+ foreach (var part in parts)
+ plate.Parts.Add(part);
+ return plate;
+ }
+
+ private static void EnlargeRectangle(Program program)
+ {
+ Assert.IsType(program.Codes[2]).EndPoint = new Vector(12, 10);
+ Assert.IsType(program.Codes[3]).EndPoint = new Vector(12, 0);
+ }
+
+ // A test-only fingerprint keeps these Core tests independent of the IO writer.
+ // Like the UI callback, it includes hole sub-program text as well as main text.
+ private static string Fingerprint(Program program) => program.ToString() + "\0"
+ + string.Join("\0", program.SubPrograms.OrderBy(pair => pair.Key)
+ .Select(pair => $"{pair.Key}:{Fingerprint(pair.Value)}"));
+
+ private static void AssertCleanAtCurrentPose(Part part, Vector location, double rotation)
+ {
+ var expected = new Part(part.BaseDrawing);
+ expected.Rotate(rotation);
+ expected.Location = location;
+
+ Assert.False(part.HasManualLeadIns);
+ Assert.False(part.LeadInsLocked);
+ Assert.Null(part.CuttingParameters);
+ Assert.Equal(location, part.Location);
+ Assert.Equal(rotation, part.Rotation, 6);
+ Assert.Equal(Fingerprint(expected.Program), Fingerprint(part.Program));
+ Assert.Equal(expected.BoundingBox.X, part.BoundingBox.X, 6);
+ Assert.Equal(expected.BoundingBox.Y, part.BoundingBox.Y, 6);
+ Assert.Equal(expected.BoundingBox.Length, part.BoundingBox.Length, 6);
+ Assert.Equal(expected.BoundingBox.Width, part.BoundingBox.Width, 6);
+ }
+}
diff --git a/OpenNest.Tests/IO/PartLeadInSerializationTests.cs b/OpenNest.Tests/IO/PartLeadInSerializationTests.cs
new file mode 100644
index 0000000..41aae68
--- /dev/null
+++ b/OpenNest.Tests/IO/PartLeadInSerializationTests.cs
@@ -0,0 +1,363 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json.Nodes;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine;
+using OpenNest.Engine.Sequencing;
+using OpenNest.Geometry;
+using OpenNest.IO;
+using OpenNest.Posts.CincinnatiCIFiber;
+
+namespace OpenNest.Tests.IO;
+
+public class PartLeadInSerializationTests
+{
+ [Fact]
+ public void RoundTrip_RotatedLeadInsKeepPoseFlagsAndBounds()
+ {
+ var nest = CreateNest();
+ var expected = nest.Plates[0].Parts[0];
+ var loaded = Read(Save(nest));
+ var actual = loaded.Plates[0].Parts[0];
+
+ Assert.True(actual.HasManualLeadIns);
+ Assert.True(actual.LeadInsLocked);
+ Assert.Equal(expected.Location, actual.Location);
+ Assert.Equal(expected.Rotation, actual.Rotation, 8);
+ AssertBox(expected.BoundingBox, actual.BoundingBox);
+ Assert.Null(actual.CuttingParameters);
+ Assert.Same(loaded.Drawings.First(), actual.BaseDrawing);
+ Assert.NotEmpty(actual.Program.SubPrograms);
+ foreach (var call in actual.Program.Codes.OfType())
+ Assert.Same(actual.Program.SubPrograms[call.Id], call.Program);
+ }
+
+ [Fact]
+ public void SingleManualLeadIn_LockedPartIsSkippedByAssignerAfterReload()
+ {
+ var nest = CreateNest();
+ var part = nest.Plates[0].Parts[0];
+ part.RemoveLeadIns();
+ part.Rotate(-part.Rotation);
+ part.ApplySingleLeadIn(Parameters(), new Vector(5, 0),
+ new Line(new Vector(10, 0), new Vector(0, 0)), ContourType.External);
+ part.LeadInsLocked = true;
+ var loaded = Read(Save(nest));
+ var plate = loaded.Plates[0];
+ var restored = plate.Parts[0];
+ var program = restored.Program;
+ plate.CuttingParameters = Parameters();
+
+ new LeadInAssigner { Sequencer = new LeftSideSequencer() }.Assign(plate);
+
+ Assert.True(restored.HasManualLeadIns);
+ Assert.True(restored.LeadInsLocked);
+ Assert.Same(program, restored.Program);
+ }
+
+ [Fact]
+ public void RoundTrip_PreservesProgramsLayersAndTabGapCodeForCode()
+ {
+ var nest = CreateNest();
+ var part = nest.Plates[0].Parts[0];
+ var main = NestWriter.GetProgramText(part.Program);
+ var subs = NestWriter.GetSubProgramsText(part.Program);
+ Assert.Contains(":LEADIN", main);
+ Assert.Contains(":LEADOUT", main);
+ Assert.Contains(":LEADIN", subs);
+
+ var withoutTab = CreateNest();
+ var parameters = Parameters();
+ parameters.TabsEnabled = false;
+ withoutTab.Plates[0].Parts[0].RemoveLeadIns();
+ withoutTab.Plates[0].Parts[0].ApplyLeadIns(parameters, new Vector(-5, -5));
+ Assert.NotEqual(main, NestWriter.GetProgramText(withoutTab.Plates[0].Parts[0].Program));
+
+ var restored = Read(Save(nest)).Plates[0].Parts[0];
+ Assert.Equal(main, NestWriter.GetProgramText(restored.Program));
+ Assert.Equal(subs, NestWriter.GetSubProgramsText(restored.Program));
+ var twice = Read(Save(Read(Save(nest)))).Plates[0].Parts[0];
+ Assert.Equal(main, NestWriter.GetProgramText(twice.Program));
+ Assert.Equal(subs, NestWriter.GetSubProgramsText(twice.Program));
+ }
+
+ [Fact]
+ public void RemoveAfterReload_RestoresCleanRotatedDrawingAtSameLocation()
+ {
+ var restored = Read(Save(CreateNest())).Plates[0].Parts[0];
+ var expected = new Part(restored.BaseDrawing);
+ expected.Rotate(restored.Rotation);
+ expected.Location = restored.Location;
+
+ restored.RemoveLeadIns();
+
+ AssertClean(expected, restored);
+ }
+
+ [Theory]
+ [InlineData("missing")]
+ [InlineData("corrupt")]
+ [InlineData("empty")]
+ [InlineData("missing-subs")]
+ [InlineData("corrupt-subs")]
+ [InlineData("empty-subs")]
+ public void DamagedPartEntry_LoadsCleanWarnsAndKeepsOtherParts(string damage)
+ {
+ var nest = CreateNest();
+ nest.Plates[0].Parts.Add((Part)nest.Plates[0].Parts[0].Clone());
+ var bytes = EditArchive(Save(nest), zip =>
+ {
+ const string name = "parts/plate-1/part-0";
+ switch (damage)
+ {
+ case "missing": zip.GetEntry(name)!.Delete(); break;
+ case "corrupt": ReplaceEntry(zip, name, "G-not-a-number\n"); break;
+ case "empty": ReplaceEntry(zip, name, "G91\n:empty program\n"); break;
+ case "missing-subs": zip.GetEntry(name + "-subs")!.Delete(); break;
+ case "corrupt-subs": ReplaceEntry(zip, name + "-subs", ":1\nG-invalid\nM99\n"); break;
+ case "empty-subs": ReplaceEntry(zip, name + "-subs", ":1\nG91\nM99\n"); break;
+ }
+ });
+ var reader = new NestReader(new MemoryStream(bytes));
+ var loaded = reader.Read();
+ var clean = new Part(loaded.Drawings.First());
+ clean.Rotate(nest.Plates[0].Parts[0].Rotation);
+ clean.Location = nest.Plates[0].Parts[0].Location;
+
+ AssertClean(clean, loaded.Plates[0].Parts[0]);
+ Assert.True(loaded.Plates[0].Parts[1].HasManualLeadIns);
+ Assert.True(loaded.Plates[0].Parts[1].LeadInsLocked);
+ var warning = Assert.Single(reader.Warnings);
+ Assert.Contains("Plate 1, part 0", warning);
+ Assert.Contains("parts/plate-1/part-0", warning);
+ }
+
+ [Fact]
+ public void LegacyFlagsWithoutProgram_LoadCleanWithoutWarning()
+ {
+ var bytes = EditArchive(Save(CreateNest()), zip =>
+ {
+ var json = JsonNode.Parse(EntryText(zip, "nest.json"))!;
+ var part = json["plates"]![0]!["parts"]![0]!.AsObject();
+ part.Remove("program");
+ part.Remove("drawingHash");
+ ReplaceEntry(zip, "nest.json", json.ToJsonString());
+ });
+ var reader = new NestReader(new MemoryStream(bytes));
+ var loaded = reader.Read();
+ Assert.False(loaded.Plates[0].Parts[0].HasManualLeadIns);
+ Assert.False(loaded.Plates[0].Parts[0].LeadInsLocked);
+ Assert.Empty(reader.Warnings);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ChangedDrawingTextOrHoleText_DropsSavedProgramSilently(bool changeHole)
+ {
+ var nest = CreateNest();
+ if (changeHole)
+ {
+ // A drawing may itself call hole sub-programs. Keep it clean but give it one.
+ var drawing = nest.Drawings.First();
+ var sub = new Program();
+ sub.Codes.Add(new RapidMove(6, 5));
+ sub.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
+ drawing.Program.SubPrograms[42] = sub;
+ drawing.Program.Codes.Add(new SubProgramCall { Id = 42, Program = sub });
+ }
+ var bytes = EditArchive(Save(nest), zip =>
+ {
+ var name = changeHole ? "programs/program-1-subs" : "programs/program-1";
+ ReplaceEntry(zip, name, EntryText(zip, name).Replace("X6Y5", "X7Y5"));
+ });
+ var reader = new NestReader(new MemoryStream(bytes));
+ var loaded = reader.Read();
+ Assert.False(loaded.Plates[0].Parts[0].HasManualLeadIns);
+ Assert.False(loaded.Plates[0].Parts[0].LeadInsLocked);
+ Assert.Empty(reader.Warnings);
+ }
+
+ [Fact]
+ public void ArchivePaths_UseSerializedPlateAndPartIndices_AndSkipCleanParts()
+ {
+ var nest = CreateNest();
+ var plate = nest.Plates[0];
+ plate.Parts.Insert(0, new Part(nest.Drawings.First()));
+ nest.Plates.Insert(0, new Plate()); // empty plates are not serialized
+ plate.CutOffs.Add(new CutOff(new Vector(30, 0), CutOffAxis.Vertical));
+ plate.RegenerateCutOffs(new CutOffSettings());
+ var second = new Plate { Size = plate.Size };
+ second.Parts.Add((Part)plate.Parts[1].Clone());
+ nest.Plates.Add(second);
+ using var zip = new ZipArchive(new MemoryStream(Save(nest)));
+ var names = zip.Entries.Where(e => e.FullName.StartsWith("parts/")).Select(e => e.FullName).ToArray();
+ Assert.Equal(new[] { "parts/plate-1/part-1", "parts/plate-1/part-1-subs",
+ "parts/plate-2/part-0", "parts/plate-2/part-0-subs" }, names);
+ }
+
+ [Fact]
+ public void RestoreRejectsNoMotionWithoutChangingPart()
+ {
+ var part = CreateNest().Plates[0].Parts[0];
+ var program = part.Program;
+ Assert.False(part.RestoreLeadInProgram(new Program(), false));
+ Assert.Same(program, part.Program);
+ Assert.True(part.HasManualLeadIns);
+ Assert.True(part.LeadInsLocked);
+ }
+
+ [Fact]
+ public void PlateCuttingParameters_RoundTripIndependentlyWithoutRegeneratingParts()
+ {
+ var nest = CreateNest();
+ var first = nest.Plates[0];
+ first.CuttingParameters = Parameters();
+ first.CuttingParameters.MachineName = "Saved machine";
+ first.CuttingParameters.Assignment.Preference = "TAIL";
+ first.CuttingParameters.Sequencing.SmallCutoutWidth = 2.75;
+ first.CuttingParameters.TabConfig = new BreakerTab
+ {
+ Size = 0.3,
+ BreakerDepth = 0.07,
+ BreakerAngle = 35,
+ TabLeadIn = new ArcLeadIn { Radius = 0.12 },
+ };
+ var second = new Plate { Size = first.Size, CuttingParameters = Parameters() };
+ second.CuttingParameters.ExternalLeadIn = new ArcLeadIn { Radius = 0.875 };
+ second.Parts.Add(new Part(nest.Drawings.First()));
+ nest.Plates.Add(second);
+ var program = NestWriter.GetProgramText(first.Parts[0].Program);
+
+ var loaded = Read(Save(nest));
+
+ var parameters = loaded.Plates[0].CuttingParameters;
+ Assert.NotNull(parameters);
+ Assert.Equal("Saved machine", parameters.MachineName);
+ Assert.Equal("TAIL", parameters.Assignment.Preference);
+ Assert.Equal(2.75, parameters.Sequencing.SmallCutoutWidth);
+ Assert.Equal(0.5, Assert.IsType(parameters.ExternalLeadIn).Length);
+ Assert.Equal(0.25, Assert.IsType(parameters.ExternalLeadOut).Length);
+ var tab = Assert.IsType(parameters.TabConfig);
+ Assert.Equal(0.3, tab.Size);
+ Assert.Equal(0.07, tab.BreakerDepth);
+ Assert.Equal(35, tab.BreakerAngle);
+ Assert.Equal(0.12, Assert.IsType(tab.TabLeadIn).Radius);
+ Assert.Equal(0.875, Assert.IsType(loaded.Plates[1].CuttingParameters.ExternalLeadIn).Radius);
+ Assert.NotSame(parameters, loaded.Plates[1].CuttingParameters);
+ Assert.Equal(program, NestWriter.GetProgramText(loaded.Plates[0].Parts[0].Program));
+ Assert.Null(loaded.Plates[0].Parts[0].CuttingParameters);
+ Assert.False(loaded.Plates[1].Parts[0].HasManualLeadIns);
+ }
+
+ [Fact]
+ public void LegacyPlateWithoutCuttingParameters_KeepsNull()
+ {
+ var bytes = EditArchive(Save(CreateNest()), zip =>
+ {
+ var json = JsonNode.Parse(EntryText(zip, "nest.json"))!;
+ json["plates"]![0]!.AsObject().Remove("cuttingParameters");
+ ReplaceEntry(zip, "nest.json", json.ToJsonString());
+ });
+ Assert.Null(Read(bytes).Plates[0].CuttingParameters);
+ }
+
+ [Fact]
+ public void CIFiberPost_MatchesBeforeAndAfterRoundTrip()
+ {
+ var nest = CreateNest();
+ Assert.Equal(Post(nest), Post(Read(Save(nest))));
+ }
+
+ private static string Post(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ new CIFiberPostProcessor(new CIFiberPostConfig()).Post(nest, stream);
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ private static byte[] EditArchive(byte[] bytes, Action edit)
+ {
+ using var stream = new MemoryStream();
+ stream.Write(bytes);
+ stream.Position = 0;
+ using (var zip = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true))
+ edit(zip);
+ return stream.ToArray();
+ }
+
+ private static string EntryText(ZipArchive zip, string name)
+ {
+ using var reader = new StreamReader(zip.GetEntry(name)!.Open());
+ return reader.ReadToEnd();
+ }
+
+ private static void ReplaceEntry(ZipArchive zip, string name, string text)
+ {
+ zip.GetEntry(name)?.Delete();
+ using var writer = new StreamWriter(zip.CreateEntry(name).Open());
+ writer.Write(text);
+ }
+
+ private static void AssertClean(Part expected, Part actual)
+ {
+ Assert.False(actual.HasManualLeadIns);
+ Assert.False(actual.LeadInsLocked);
+ Assert.Null(actual.CuttingParameters);
+ Assert.Equal(expected.Location, actual.Location);
+ Assert.Equal(expected.Rotation, actual.Rotation, 8);
+ AssertBox(expected.BoundingBox, actual.BoundingBox);
+ Assert.Equal(NestWriter.GetProgramText(expected.Program), NestWriter.GetProgramText(actual.Program));
+ }
+
+ private static CuttingParameters Parameters() => new()
+ {
+ ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
+ ExternalLeadOut = new LineLeadOut { Length = 0.25 },
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
+ TabsEnabled = true,
+ TabConfig = new NormalTab { Size = 0.15 },
+ };
+
+ private static Nest CreateNest()
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(0, 10));
+ program.Codes.Add(new LinearMove(10, 10));
+ program.Codes.Add(new LinearMove(10, 0));
+ program.Codes.Add(new LinearMove(0, 0));
+ program.Codes.Add(new RapidMove(6, 5));
+ program.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
+ var drawing = new Drawing("square-with-hole", program);
+ var part = new Part(drawing);
+ part.Rotate(System.Math.PI / 2);
+ part.Offset(20, 5);
+ part.ApplyLeadIns(Parameters(), new Vector(-5, -5));
+ part.LeadInsLocked = true;
+ var nest = new Nest { Name = "lead-in-round-trip" };
+ nest.Drawings.Add(drawing);
+ var plate = new Plate { Size = new Size(48, 96) };
+ plate.Parts.Add(part);
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static byte[] Save(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ private static Nest Read(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read();
+
+ private static void AssertBox(Box expected, Box actual)
+ {
+ Assert.Equal(expected.X, actual.X, 8);
+ Assert.Equal(expected.Y, actual.Y, 8);
+ Assert.Equal(expected.Length, actual.Length, 8);
+ Assert.Equal(expected.Width, actual.Width, 8);
+ }
+}
diff --git a/OpenNest/Forms/CuttingParametersSerializer.cs b/OpenNest/Forms/CuttingParametersSerializer.cs
index f2c627e..a814532 100644
--- a/OpenNest/Forms/CuttingParametersSerializer.cs
+++ b/OpenNest/Forms/CuttingParametersSerializer.cs
@@ -1,200 +1,13 @@
-using System.Text.Json;
using OpenNest.CNC.CuttingStrategy;
-namespace OpenNest.Forms
+namespace OpenNest.Forms;
+
+// Keep the desktop settings API stable while sharing the mapping with nest persistence.
+public static class CuttingParametersSerializer
{
- public static class CuttingParametersSerializer
- {
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- WriteIndented = false,
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- };
+ public static string Serialize(CuttingParameters parameters) =>
+ IO.CuttingParametersSerializer.Serialize(parameters);
- public static string Serialize(CuttingParameters p)
- {
- var dto = new CuttingParametersDto
- {
- ExternalLeadIn = ToDto(p.ExternalLeadIn),
- ExternalLeadOut = ToLeadOutDto(p.ExternalLeadOut),
- InternalLeadIn = ToDto(p.InternalLeadIn),
- InternalLeadOut = ToLeadOutDto(p.InternalLeadOut),
- ArcCircleLeadIn = ToDto(p.ArcCircleLeadIn),
- ArcCircleLeadOut = ToLeadOutDto(p.ArcCircleLeadOut),
- TabsEnabled = p.TabsEnabled,
- TabWidth = p.TabConfig?.Size ?? 0.25,
- PierceClearance = p.PierceClearance,
- RoundLeadInAngles = p.RoundLeadInAngles,
- LeadInAngleIncrement = p.LeadInAngleIncrement,
- AutoTabMinSize = p.AutoTabMinSize,
- AutoTabMaxSize = p.AutoTabMaxSize,
- };
- return JsonSerializer.Serialize(dto, JsonOptions);
- }
-
- public static CuttingParameters Deserialize(string json)
- {
- var dto = JsonSerializer.Deserialize(json, JsonOptions);
- if (dto == null)
- return new CuttingParameters();
-
- return new CuttingParameters
- {
- ExternalLeadIn = FromDto(dto.ExternalLeadIn),
- ExternalLeadOut = FromLeadOutDto(dto.ExternalLeadOut),
- InternalLeadIn = FromDto(dto.InternalLeadIn),
- InternalLeadOut = FromLeadOutDto(dto.InternalLeadOut),
- ArcCircleLeadIn = FromDto(dto.ArcCircleLeadIn),
- ArcCircleLeadOut = FromLeadOutDto(dto.ArcCircleLeadOut),
- TabsEnabled = dto.TabsEnabled,
- TabConfig = new NormalTab { Size = dto.TabWidth },
- PierceClearance = dto.PierceClearance,
- RoundLeadInAngles = dto.RoundLeadInAngles,
- LeadInAngleIncrement =
- dto.LeadInAngleIncrement > 0 ? dto.LeadInAngleIncrement : 5.0,
- AutoTabMinSize = dto.AutoTabMinSize,
- AutoTabMaxSize = dto.AutoTabMaxSize,
- };
- }
-
- private static LeadInDto ToDto(LeadIn leadIn)
- {
- return leadIn switch
- {
- LineLeadIn line => new LeadInDto
- {
- Type = "Line",
- Length = line.Length,
- ApproachAngle = line.ApproachAngle,
- },
- ArcLeadIn arc => new LeadInDto { Type = "Arc", Radius = arc.Radius },
- LineArcLeadIn la => new LeadInDto
- {
- Type = "LineArc",
- LineLength = la.LineLength,
- ArcRadius = la.ArcRadius,
- ApproachAngle = la.ApproachAngle,
- },
- CleanHoleLeadIn ch => new LeadInDto
- {
- Type = "CleanHole",
- LineLength = ch.LineLength,
- ArcRadius = ch.ArcRadius,
- Kerf = ch.Kerf,
- },
- LineLineLeadIn ll => new LeadInDto
- {
- Type = "LineLine",
- Length1 = ll.Length1,
- Angle1 = ll.ApproachAngle1,
- Length2 = ll.Length2,
- Angle2 = ll.ApproachAngle2,
- },
- _ => new LeadInDto { Type = "None" },
- };
- }
-
- private static LeadIn FromDto(LeadInDto dto)
- {
- if (dto == null)
- return new NoLeadIn();
- return dto.Type switch
- {
- "Line" => new LineLeadIn { Length = dto.Length, ApproachAngle = dto.ApproachAngle },
- "Arc" => new ArcLeadIn { Radius = dto.Radius },
- "LineArc" => new LineArcLeadIn
- {
- LineLength = dto.LineLength,
- ArcRadius = dto.ArcRadius,
- ApproachAngle = dto.ApproachAngle,
- },
- "CleanHole" => new CleanHoleLeadIn
- {
- LineLength = dto.LineLength,
- ArcRadius = dto.ArcRadius,
- Kerf = dto.Kerf,
- },
- "LineLine" => new LineLineLeadIn
- {
- Length1 = dto.Length1,
- ApproachAngle1 = dto.Angle1,
- Length2 = dto.Length2,
- ApproachAngle2 = dto.Angle2,
- },
- _ => new NoLeadIn(),
- };
- }
-
- private static LeadOutDto ToLeadOutDto(LeadOut leadOut)
- {
- return leadOut switch
- {
- LineLeadOut line => new LeadOutDto
- {
- Type = "Line",
- Length = line.Length,
- ApproachAngle = line.ApproachAngle,
- },
- ArcLeadOut arc => new LeadOutDto { Type = "Arc", Radius = arc.Radius },
- _ => new LeadOutDto { Type = "None" },
- };
- }
-
- private static LeadOut FromLeadOutDto(LeadOutDto dto)
- {
- if (dto == null)
- return new NoLeadOut();
- return dto.Type switch
- {
- "Line" => new LineLeadOut
- {
- Length = dto.Length,
- ApproachAngle = dto.ApproachAngle,
- },
- "Arc" => new ArcLeadOut { Radius = dto.Radius },
- _ => new NoLeadOut(),
- };
- }
-
- private class CuttingParametersDto
- {
- public LeadInDto ExternalLeadIn { get; set; }
- public LeadOutDto ExternalLeadOut { get; set; }
- public LeadInDto InternalLeadIn { get; set; }
- public LeadOutDto InternalLeadOut { get; set; }
- public LeadInDto ArcCircleLeadIn { get; set; }
- public LeadOutDto ArcCircleLeadOut { get; set; }
- public bool TabsEnabled { get; set; }
- public double TabWidth { get; set; }
- public double PierceClearance { get; set; }
- public bool RoundLeadInAngles { get; set; }
- public double LeadInAngleIncrement { get; set; }
- public double AutoTabMinSize { get; set; }
- public double AutoTabMaxSize { get; set; }
- }
-
- private class LeadInDto
- {
- public string Type { get; set; } = "None";
- public double Length { get; set; }
- public double ApproachAngle { get; set; }
- public double Radius { get; set; }
- public double LineLength { get; set; }
- public double ArcRadius { get; set; }
- public double Kerf { get; set; }
- public double Length1 { get; set; }
- public double Angle1 { get; set; }
- public double Length2 { get; set; }
- public double Angle2 { get; set; }
- }
-
- private class LeadOutDto
- {
- public string Type { get; set; } = "None";
- public double Length { get; set; }
- public double ApproachAngle { get; set; }
- public double Radius { get; set; }
- public double GapSize { get; set; }
- }
- }
+ public static CuttingParameters Deserialize(string json) =>
+ IO.CuttingParametersSerializer.Deserialize(json);
}
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 99845df..769e402 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -891,6 +891,11 @@ namespace OpenNest.Forms
if (Nest.Drawings.Count == 0)
return;
+ // Capture before loading the editor: its conversion can mutate programs in place.
+ var snapshot = DrawingProgramSnapshot.Capture(
+ Nest.Drawings,
+ program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program)
+ );
var converter = new CadConverterForm();
converter.LoadDrawings(Nest.Drawings);
@@ -925,11 +930,16 @@ namespace OpenNest.Forms
foreach (var d in newByName.Values)
Nest.Drawings.Add(d);
- // Refresh all parts to use the updated programs
- foreach (var plate in Nest.Plates)
- foreach (var part in plate.Parts)
- if (!part.BaseDrawing.IsCutOff)
- part.Update();
+ // Leave unchanged parts' lead-ins, tabs, locks and program instances intact.
+ var updatedParts = snapshot.UpdateChangedParts(Nest.Plates).ToHashSet();
+ foreach (var layoutPart in PlateView.Parts)
+ {
+ if (!updatedParts.Contains(layoutPart.BasePart))
+ continue;
+
+ layoutPart.IsDirty = true;
+ layoutPart.InvalidateOffset();
+ }
UpdateDrawingList();
PlateView.Invalidate();
@@ -1064,9 +1074,12 @@ namespace OpenNest.Forms
{
form.SaveDrawing(drawing);
- foreach (var part in PlateView.Parts)
- part.Update();
+ // Metadata edits only refresh color; never rebuild the placed part program.
+ foreach (var layoutPart in PlateView.Parts)
+ if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing))
+ layoutPart.Color = drawing.Color;
+ UpdateDrawingList();
PlateView.Invalidate();
}
}
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 58028e9..4ce836e 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -566,6 +566,9 @@ namespace OpenNest.Forms
var reader = new NestReader(dlg.FileName);
var nest = reader.Read();
LoadNest(nest);
+ if (reader.Warnings.Count > 0)
+ MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
+ "Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
diff --git a/README.md b/README.md
index 56cb6b5..d69fb18 100644
--- a/README.md
+++ b/README.md
@@ -96,7 +96,7 @@ Jobs-only API: engines implement `INestingEngine.Solve(NestJob)`; only `NestJobR
## File Format
-`.nest` files are ZIP archives: `nest.json` (metadata, plates, drawings, placements), `programs/program-N` (G-code per drawing), optional `entities/`, sub-programs, and cached best-fit data.
+`.nest` files are ZIP archives containing drawing programs, metadata, plates, and placements. Saved nests retain each part's lead-ins, lead-outs, tab gaps, and locks, plus the plate's cutting settings. Changing a drawing's geometry removes obsolete cutting paths from its parts; name, quantity, and color edits preserve them. See the [file-format reference](docs/nest-file-format.md) for compatibility and recovery behavior.
## Supported Formats
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
new file mode 100644
index 0000000..5c9d422
--- /dev/null
+++ b/docs/nest-file-format.md
@@ -0,0 +1,78 @@
+# Nest file format and saved cutting state
+
+`.nest` is a ZIP archive with additive version-2 JSON metadata in `nest.json`.
+Old files remain readable. Older applications ignore the new cutting-state fields;
+opening and saving with an older application loses that state.
+
+## Programs and placements
+
+- `programs/program-N` is drawing N's clean G-code program; optional
+ `programs/program-N-subs` contains its hole sub-programs.
+- Each serialized plate has a one-based `id`. Its `parts` array excludes cut-offs;
+ each placement retains `drawingId`, `x`, `y`, and `rotation` (radians).
+- A placement with `HasManualLeadIns` also writes `parts/plate-P/part-K`, and
+ `parts/plate-P/part-K-subs` when it has hole sub-programs. P is the serialized
+ plate ID; K is the zero-based index in that plate's serialized parts array.
+ Empty plates and cut-offs do not consume these part indices.
+- The placement's `program` field references that ZIP entry. Its G-code is saved
+ in the already-rotated local part frame, with the placed origin at `(x, y)`.
+ Existing `:LEADIN`, `:LEADOUT`, and other layer tags, tab gaps, and G65 calls
+ are retained rather than regenerated by a cutting strategy during load.
+ Hole IDs may be negative: they are identifiers, not validity sentinels.
+- `drawingHash` protects against restoring obsolete cutting paths after an
+ externally edited drawing. It is uppercase SHA-256 of UTF-8 text composed of
+ the main text's decimal UTF-16 character count, `:`, main text, and sub-program
+ text (empty when absent). The hash includes both drawing entries so changing a
+ hole alone invalidates the placed cutting program. Text is read without a BOM;
+ otherwise whitespace and line endings participate in the hash.
+
+The reader constructs and places a clean part first, then installs its saved
+program without rotating it again. The drawing reference remains authoritative
+for Remove Lead-ins: removing them restores the clean drawing at the saved pose.
+Successful restoration marks the part as carrying lead-ins and restores its
+`leadInsLocked` flag. The per-part `CuttingParameters` reference remains null;
+the saved program, not strategy regeneration, determines the cutting geometry.
+
+Missing `program` references in legacy files load clean and silently, even when
+old transient flags were true. A missing or mismatched drawing hash likewise
+loads clean silently. A referenced but missing, unparseable, or motionless part
+program, or an unresolved/empty hole sub-program, leaves that part clean and
+unlocked without discarding the rest of the nest. `NestReader.Warnings` identifies
+the plate, zero-based part index, and entry; File > Open displays these warnings.
+The program reader is not a general G-code or geometric safety validator; the
+saved cutting geometry is not validated against the drawing.
+
+## Plate cutting parameters
+
+Each plate's optional `cuttingParameters` stores the settings used by Assign
+Lead-ins and Place Lead-in. Missing settings stay null, preserving the old default
+selection behavior. `OpenNest.IO.CuttingParametersSerializer` owns the shared
+cross-platform DTO mapping; the desktop settings serializer delegates to it so
+existing saved settings keep their JSON contract. Settings are independent of
+the saved part programs: restoring settings never regenerates those programs.
+
+## Drawing edits
+
+`DrawingProgramSnapshot` captures serialized main and hole-program text before
+the converter is loaded. After an accepted edit, only parts referencing drawings
+whose program text changed are rebuilt (lead-ins, tabs, and locks cleared); their
+location and rotation remain unchanged. Reference identity, not editable names,
+keys this comparison. The desktop invalidates the affected layout graphics.
+Name, quantity, and color edits leave placed programs alone. The properties
+editor previously called `LayoutPart.Update`, a color refresh, not `Part.Update`;
+it now explicitly refreshes only the edited drawing's layout colors.
+
+## Verification and limits
+
+The automated tests cover save/reopen/re-save, rotated/manual/locked parts,
+lead-outs and tab gaps, hole binding, clean removal, stale drawings, damaged
+entries isolated from other parts, plate settings, and CI Fiber posting before
+and after reload. Drawing-change decision logic runs on Linux; desktop rendering,
+dialog values, and clicks require Windows acceptance.
+
+Known separate issue: the cutting strategy currently labels contour cuts
+`Display`. CI Fiber includes them, but GravographIS skips Display. Saving restores
+the existing layers; this change does not alter the strategy or either post.
+Future hardening: make numeric G-code serialization/parsing culture-invariant
+as a coordinated compatibility change; do not change just one side. Program
+content deduplication is optional and must not change per-part ownership.