feat(io): persist part cutting programs and plate parameters
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user