feat(io): persist part cutting programs and plate parameters

This commit is contained in:
aj
2026-09-28 19:17:59 -04:00
parent 74302761ce
commit b254a0bd3e
16 changed files with 1765 additions and 265 deletions
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
namespace OpenNest;
/// <summary>
/// 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.
/// </summary>
public sealed class DrawingProgramSnapshot
{
private readonly Dictionary<Drawing, string> programs;
private readonly Func<Program, string> fingerprint;
private DrawingProgramSnapshot(
Dictionary<Drawing, string> programs,
Func<Program, string> fingerprint)
{
this.programs = programs;
this.fingerprint = fingerprint;
}
/// <summary>
/// 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.
/// </summary>
public static DrawingProgramSnapshot Capture(
IEnumerable<Drawing> drawings,
Func<Program, string> fingerprint)
{
ArgumentNullException.ThrowIfNull(drawings);
ArgumentNullException.ThrowIfNull(fingerprint);
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
foreach (var drawing in drawings)
{
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
programs.Add(drawing, fingerprint(drawing.Program));
}
return new DrawingProgramSnapshot(programs, fingerprint);
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
{
ArgumentNullException.ThrowIfNull(plates);
var changed = new HashSet<Drawing>(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<Part>();
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (!changed.Contains(part.BaseDrawing))
continue;
part.Update();
updated.Add(part);
}
}
return updated;
}
}
+24
View File
@@ -111,6 +111,30 @@ namespace OpenNest
UpdateBounds(); UpdateBounds();
} }
/// <summary>
/// Installs an owned, already-rotated saved cutting program without rotating it again.
/// The current pose remains the clean-drawing pose used by RemoveLeadIns.
/// </summary>
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() public void RemoveLeadIns()
{ {
var rotation = preLeadInRotation; var rotation = preLeadInRotation;
@@ -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<LineLineLeadIn>(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<LineLeadOut>(restored.ExternalLeadOut);
Assert.Equal(0.5, lineOut.Length);
Assert.Equal(43, lineOut.ApproachAngle);
Assert.Equal(0.12, Assert.IsType<ArcLeadIn>(restored.InternalLeadIn).Radius);
Assert.Equal(0.09, Assert.IsType<ArcLeadOut>(restored.InternalLeadOut).Radius);
var cleanHole = Assert.IsType<CleanHoleLeadIn>(restored.ArcCircleLeadIn);
Assert.Equal(0.8, cleanHole.LineLength);
Assert.Equal(0.3, cleanHole.ArcRadius);
Assert.Equal(0.04, cleanHole.Kerf);
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
Assert.True(restored.TabsEnabled);
Assert.Equal(0.375, Assert.IsType<NormalTab>(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<NoLeadIn>(restored.ExternalLeadIn);
Assert.IsType<NoLeadIn>(restored.InternalLeadIn);
Assert.IsType<NoLeadIn>(restored.ArcCircleLeadIn);
Assert.IsType<NoLeadOut>(restored.ExternalLeadOut);
Assert.IsType<NoLeadOut>(restored.InternalLeadOut);
Assert.IsType<NoLeadOut>(restored.ArcCircleLeadOut);
Assert.Equal(0, Assert.IsType<NormalTab>(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<JsonException>(() => CuttingParametersSerializer.Deserialize(json));
}
[Fact]
public void Deserialize_UnknownLeadTypes_UsesNoLead()
{
var restored = CuttingParametersSerializer.Deserialize("""
{ "externalLeadIn": { "type": "FutureLead" },
"externalLeadOut": { "type": "FutureLead" } }
""");
Assert.IsType<NoLeadIn>(restored.ExternalLeadIn);
Assert.IsType<NoLeadOut>(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<NormalTab>(
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<CuttingParametersDto>(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<CuttingParametersDto>(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<CuttingParametersDto>(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));
}
}
+97
View File
@@ -0,0 +1,97 @@
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.IO;
/// <summary>
/// JSON-safe cutting parameters shared by nest files and desktop settings.
/// Lead and tab discriminators avoid serializing abstract domain types.
/// </summary>
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;
}
}
+291
View File
@@ -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,
};
/// <summary>Writes desktop settings, retaining the legacy null-tab width fallback.</summary>
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);
}
/// <summary>Reads desktop settings with their historical defaults and angle fallback.</summary>
public static CuttingParameters Deserialize(string json)
{
var dto = JsonSerializer.Deserialize<CuttingParametersDto>(json, JsonOptions);
var parameters = FromDto(dto) ?? new CuttingParameters();
if (parameters.LeadInAngleIncrement <= 0)
parameters.LeadInAngleIncrement = 5.0;
return parameters;
}
/// <summary>Captures an owned snapshot. Null stays null for absent plate parameters.</summary>
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),
};
}
/// <summary>Restores an owned snapshot without the desktop settings' value normalization.</summary>
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,
};
}
+3
View File
@@ -66,6 +66,7 @@ namespace OpenNest.IO
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
public SpacingDto EdgeSpacing { get; init; } = new(); public SpacingDto EdgeSpacing { get; init; } = new();
public double GrainAngle { get; init; } public double GrainAngle { get; init; }
public CuttingParametersDto CuttingParameters { get; init; }
public List<PartDto> Parts { get; init; } = new(); public List<PartDto> Parts { get; init; } = new();
public List<CutOffDto> CutOffs { get; init; } = new(); public List<CutOffDto> CutOffs { get; init; } = new();
} }
@@ -78,6 +79,8 @@ namespace OpenNest.IO
public double Rotation { get; init; } public double Rotation { get; init; }
public bool HasManualLeadIns { get; init; } public bool HasManualLeadIns { get; init; }
public bool LeadInsLocked { get; init; } public bool LeadInsLocked { get; init; }
public string Program { get; init; }
public string DrawingHash { get; init; }
} }
public record CutOffDto public record CutOffDto
+57 -19
View File
@@ -18,6 +18,11 @@ namespace OpenNest.IO
{ {
private readonly Stream stream; private readonly Stream stream;
private readonly ZipArchive zipArchive; private readonly ZipArchive zipArchive;
private readonly Dictionary<int, string> drawingHashes = new();
private readonly List<string> warnings = new();
/// <summary>Part programs that could not be restored; other parts still load.</summary>
public IReadOnlyList<string> Warnings => warnings.AsReadOnly();
public NestReader(string file) public NestReader(string file)
{ {
@@ -63,29 +68,59 @@ namespace OpenNest.IO
var programs = new Dictionary<int, Program>(); var programs = new Dictionary<int, Program>();
for (var i = 1; i <= count; i++) for (var i = 1; i <= count; i++)
{ {
var entry = zipArchive.GetEntry($"programs/program-{i}"); var name = $"programs/program-{i}";
if (entry == null) if (zipArchive.GetEntry(name) == null)
continue; continue;
programs[i] = ReadProgram(name, out var hash);
using var entryStream = entry.Open(); drawingHashes[i] = hash;
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);
}
} }
return programs; 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<SubProgramCall>())
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) private static void ReadSubPrograms(Program parent, Stream stream)
{ {
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
@@ -357,15 +392,18 @@ namespace OpenNest.IO
p.EdgeSpacing.Top p.EdgeSpacing.Top
); );
plate.GrainAngle = p.GrainAngle; 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)) if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
continue; continue;
var part = new Part(dwg); var part = new Part(dwg);
part.Rotate(partDto.Rotation); part.Rotate(partDto.Rotation);
part.Offset(new Vector(partDto.X, partDto.Y)); part.Offset(new Vector(partDto.X, partDto.Y));
RestorePartProgram(part, partDto, p.Id, partIndex);
plate.Parts.Add(part); plate.Parts.Add(part);
} }
+61 -43
View File
@@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
@@ -18,6 +20,8 @@ namespace OpenNest.IO
private readonly Nest nest; private readonly Nest nest;
private Dictionary<int, Drawing> drawingDict; private Dictionary<int, Drawing> drawingDict;
private readonly Dictionary<int, string> drawingHashes = new();
private readonly Dictionary<string, Program> partPrograms = new();
public NestWriter(Nest nest) public NestWriter(Nest nest)
{ {
@@ -38,8 +42,12 @@ namespace OpenNest.IO
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
WriteNestJson(zipArchive); drawingHashes.Clear();
partPrograms.Clear();
WritePrograms(zipArchive); WritePrograms(zipArchive);
WriteNestJson(zipArchive);
foreach (var entry in partPrograms)
WriteProgramEntry(zipArchive, entry.Key, entry.Value);
WriteEntities(zipArchive); WriteEntities(zipArchive);
WriteBestFits(zipArchive); WriteBestFits(zipArchive);
@@ -48,6 +56,7 @@ namespace OpenNest.IO
private void SetDrawingIds() private void SetDrawingIds()
{ {
drawingDict.Clear();
var id = 1; var id = 1;
foreach (var drawing in nest.Drawings) foreach (var drawing in nest.Drawings)
{ {
@@ -207,6 +216,11 @@ namespace OpenNest.IO
var match = drawingDict var match = drawingDict
.Where(dwg => dwg.Value == part.BaseDrawing) .Where(dwg => dwg.Value == part.BaseDrawing)
.FirstOrDefault(); .FirstOrDefault();
var programName = part.HasManualLeadIns
? $"parts/plate-{id}/part-{parts.Count}"
: null;
if (programName != null)
partPrograms.Add(programName, part.Program);
parts.Add( parts.Add(
new PartDto new PartDto
{ {
@@ -216,6 +230,8 @@ namespace OpenNest.IO
Rotation = part.Rotation, Rotation = part.Rotation,
HasManualLeadIns = part.HasManualLeadIns, HasManualLeadIns = part.HasManualLeadIns,
LeadInsLocked = part.LeadInsLocked, LeadInsLocked = part.LeadInsLocked,
Program = programName,
DrawingHash = programName == null ? null : drawingHashes[match.Key],
} }
); );
} }
@@ -264,6 +280,7 @@ namespace OpenNest.IO
Parts = parts, Parts = parts,
CutOffs = cutoffs, CutOffs = cutoffs,
GrainAngle = plate.GrainAngle, GrainAngle = plate.GrainAngle,
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
} }
); );
} }
@@ -342,43 +359,44 @@ namespace OpenNest.IO
private void WritePrograms(ZipArchive zipArchive) private void WritePrograms(ZipArchive zipArchive)
{ {
foreach (var kvp in drawingDict.OrderBy(k => k.Key)) foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{ drawingHashes[kvp.Key] = WriteProgramEntry(
var name = $"programs/program-{kvp.Key}"; zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
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);
}
} }
private void WriteSubPrograms( private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
ZipArchive zipArchive,
int drawingId,
Dictionary<int, Program> subPrograms
)
{ {
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs"); var text = GetProgramText(program);
using var entryStream = entry.Open(); var subs = GetSubProgramsText(program);
using var writer = new StreamWriter(entryStream, Encoding.UTF8); 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)));
/// <summary>Serializes the hole programs using the same text as the nest archive.</summary>
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.Key}");
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91"); WriteProgram(writer, kvp.Value);
foreach (var code in kvp.Value.Codes)
writer.WriteLine(GetCodeString(code));
writer.WriteLine("M99"); writer.WriteLine("M99");
} }
return writer.ToString();
} }
private void WriteEntities(ZipArchive zipArchive) private void WriteEntities(ZipArchive zipArchive)
@@ -402,11 +420,16 @@ namespace OpenNest.IO
} }
} }
private void WriteDrawing(Stream stream, Drawing drawing) /// <summary>Serializes a program in its current local frame, without transforming it.</summary>
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 // Emit variable definitions before G-code
foreach (var v in program.Variables.Values) foreach (var v in program.Variables.Values)
@@ -421,16 +444,11 @@ namespace OpenNest.IO
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
for (var i = 0; i < drawing.Program.Length; ++i) foreach (var code in program.Codes)
{
var code = drawing.Program[i];
writer.WriteLine(GetCodeString(code)); writer.WriteLine(GetCodeString(code));
}
stream.Position = 0;
} }
private string FormatCoord( private static string FormatCoord(
double value, double value,
string axis, string axis,
Dictionary<string, string> variableRefs Dictionary<string, string> variableRefs
@@ -441,7 +459,7 @@ namespace OpenNest.IO
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat); return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
} }
private string GetCodeString(ICode code) private static string GetCodeString(ICode code)
{ {
switch (code.Type) switch (code.Type)
{ {
@@ -545,7 +563,7 @@ namespace OpenNest.IO
return string.Empty; return string.Empty;
} }
private string GetLayerString(LayerType layer) private static string GetLayerString(LayerType layer)
{ {
switch (layer) switch (layer)
{ {
@@ -96,6 +96,24 @@ public class CIFiberSampleRegressionTests
return result; 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] [SkippableFact]
public void Post_ReconstructedNest_MatchesSampleCounts() public void Post_ReconstructedNest_MatchesSampleCounts()
{ {
@@ -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<ArcMove>(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<LinearMove>(), 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<LinearMove>(program.Codes[2]).EndPoint = new Vector(12, 10);
Assert.IsType<LinearMove>(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);
}
}
@@ -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<SubProgramCall>())
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<LineLeadIn>(parameters.ExternalLeadIn).Length);
Assert.Equal(0.25, Assert.IsType<LineLeadOut>(parameters.ExternalLeadOut).Length);
var tab = Assert.IsType<BreakerTab>(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<ArcLeadIn>(tab.TabLeadIn).Radius);
Assert.Equal(0.875, Assert.IsType<ArcLeadIn>(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<ZipArchive> 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);
}
}
+8 -195
View File
@@ -1,200 +1,13 @@
using System.Text.Json;
using OpenNest.CNC.CuttingStrategy; 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 public static string Serialize(CuttingParameters parameters) =>
{ IO.CuttingParametersSerializer.Serialize(parameters);
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public static string Serialize(CuttingParameters p) public static CuttingParameters Deserialize(string json) =>
{ IO.CuttingParametersSerializer.Deserialize(json);
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<CuttingParametersDto>(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; }
}
}
} }
+20 -7
View File
@@ -891,6 +891,11 @@ namespace OpenNest.Forms
if (Nest.Drawings.Count == 0) if (Nest.Drawings.Count == 0)
return; 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(); var converter = new CadConverterForm();
converter.LoadDrawings(Nest.Drawings); converter.LoadDrawings(Nest.Drawings);
@@ -925,11 +930,16 @@ namespace OpenNest.Forms
foreach (var d in newByName.Values) foreach (var d in newByName.Values)
Nest.Drawings.Add(d); Nest.Drawings.Add(d);
// Refresh all parts to use the updated programs // Leave unchanged parts' lead-ins, tabs, locks and program instances intact.
foreach (var plate in Nest.Plates) var updatedParts = snapshot.UpdateChangedParts(Nest.Plates).ToHashSet();
foreach (var part in plate.Parts) foreach (var layoutPart in PlateView.Parts)
if (!part.BaseDrawing.IsCutOff) {
part.Update(); if (!updatedParts.Contains(layoutPart.BasePart))
continue;
layoutPart.IsDirty = true;
layoutPart.InvalidateOffset();
}
UpdateDrawingList(); UpdateDrawingList();
PlateView.Invalidate(); PlateView.Invalidate();
@@ -1064,9 +1074,12 @@ namespace OpenNest.Forms
{ {
form.SaveDrawing(drawing); form.SaveDrawing(drawing);
foreach (var part in PlateView.Parts) // Metadata edits only refresh color; never rebuild the placed part program.
part.Update(); foreach (var layoutPart in PlateView.Parts)
if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing))
layoutPart.Color = drawing.Color;
UpdateDrawingList();
PlateView.Invalidate(); PlateView.Invalidate();
} }
} }
+3
View File
@@ -566,6 +566,9 @@ namespace OpenNest.Forms
var reader = new NestReader(dlg.FileName); var reader = new NestReader(dlg.FileName);
var nest = reader.Read(); var nest = reader.Read();
LoadNest(nest); LoadNest(nest);
if (reader.Warnings.Count > 0)
MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
"Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
} }
+1 -1
View File
@@ -96,7 +96,7 @@ Jobs-only API: engines implement `INestingEngine.Solve(NestJob)`; only `NestJobR
## File Format ## 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 ## Supported Formats
+78
View File
@@ -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.