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.