mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 03:08:49 -04:00
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();
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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 SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public double GrainAngle { get; init; }
|
||||
public CuttingParametersDto CuttingParameters { get; init; }
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
public List<CutOffDto> CutOffs { get; init; } = new();
|
||||
}
|
||||
@@ -78,6 +79,8 @@ namespace OpenNest.IO
|
||||
public double Rotation { get; init; }
|
||||
public bool HasManualLeadIns { get; init; }
|
||||
public bool LeadInsLocked { get; init; }
|
||||
public string Program { get; init; }
|
||||
public string DrawingHash { get; init; }
|
||||
}
|
||||
|
||||
public record CutOffDto
|
||||
|
||||
+57
-19
@@ -18,6 +18,11 @@ namespace OpenNest.IO
|
||||
{
|
||||
private readonly Stream stream;
|
||||
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)
|
||||
{
|
||||
@@ -63,29 +68,59 @@ namespace OpenNest.IO
|
||||
var programs = new Dictionary<int, Program>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"programs/program-{i}");
|
||||
if (entry == null)
|
||||
var name = $"programs/program-{i}";
|
||||
if (zipArchive.GetEntry(name) == null)
|
||||
continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
var memStream = new MemoryStream();
|
||||
entryStream.CopyTo(memStream);
|
||||
memStream.Position = 0;
|
||||
|
||||
var reader = new ProgramReader(memStream);
|
||||
programs[i] = reader.Read();
|
||||
|
||||
// Read sub-programs if present
|
||||
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
|
||||
if (subsEntry != null)
|
||||
{
|
||||
using var subsStream = subsEntry.Open();
|
||||
ReadSubPrograms(programs[i], subsStream);
|
||||
}
|
||||
programs[i] = ReadProgram(name, out var hash);
|
||||
drawingHashes[i] = hash;
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
private Program ReadProgram(string name, out string hash)
|
||||
{
|
||||
var text = ReadEntry(name);
|
||||
var subs = zipArchive.GetEntry(name + "-subs") == null ? "" : ReadEntry(name + "-subs");
|
||||
hash = NestWriter.GetDrawingHash(text, subs);
|
||||
using var programStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
|
||||
var program = new ProgramReader(programStream).Read();
|
||||
if (subs.Length > 0)
|
||||
{
|
||||
using var subsStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(subs));
|
||||
ReadSubPrograms(program, subsStream);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private void RestorePartProgram(Part part, PartDto dto, int plateId, int partIndex)
|
||||
{
|
||||
// Old files had only transient flags. They still load clean and silently.
|
||||
if (string.IsNullOrEmpty(dto.Program))
|
||||
return;
|
||||
|
||||
// A missing hash cannot establish that this program belongs to the current drawing.
|
||||
if (!drawingHashes.TryGetValue(dto.DrawingId, out var hash)
|
||||
|| !string.Equals(dto.DrawingHash, hash, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var program = ReadProgram(dto.Program, out _);
|
||||
foreach (var call in program.Codes.OfType<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)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
@@ -357,15 +392,18 @@ namespace OpenNest.IO
|
||||
p.EdgeSpacing.Top
|
||||
);
|
||||
plate.GrainAngle = p.GrainAngle;
|
||||
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
|
||||
|
||||
foreach (var partDto in p.Parts)
|
||||
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
|
||||
{
|
||||
var partDto = p.Parts[partIndex];
|
||||
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
|
||||
continue;
|
||||
|
||||
var part = new Part(dwg);
|
||||
part.Rotate(partDto.Rotation);
|
||||
part.Offset(new Vector(partDto.X, partDto.Y));
|
||||
RestorePartProgram(part, partDto, p.Id, partIndex);
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
|
||||
+61
-43
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
@@ -18,6 +20,8 @@ namespace OpenNest.IO
|
||||
|
||||
private readonly Nest nest;
|
||||
private Dictionary<int, Drawing> drawingDict;
|
||||
private readonly Dictionary<int, string> drawingHashes = new();
|
||||
private readonly Dictionary<string, Program> partPrograms = new();
|
||||
|
||||
public NestWriter(Nest nest)
|
||||
{
|
||||
@@ -38,8 +42,12 @@ namespace OpenNest.IO
|
||||
|
||||
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
drawingHashes.Clear();
|
||||
partPrograms.Clear();
|
||||
WritePrograms(zipArchive);
|
||||
WriteNestJson(zipArchive);
|
||||
foreach (var entry in partPrograms)
|
||||
WriteProgramEntry(zipArchive, entry.Key, entry.Value);
|
||||
WriteEntities(zipArchive);
|
||||
WriteBestFits(zipArchive);
|
||||
|
||||
@@ -48,6 +56,7 @@ namespace OpenNest.IO
|
||||
|
||||
private void SetDrawingIds()
|
||||
{
|
||||
drawingDict.Clear();
|
||||
var id = 1;
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
@@ -207,6 +216,11 @@ namespace OpenNest.IO
|
||||
var match = drawingDict
|
||||
.Where(dwg => dwg.Value == part.BaseDrawing)
|
||||
.FirstOrDefault();
|
||||
var programName = part.HasManualLeadIns
|
||||
? $"parts/plate-{id}/part-{parts.Count}"
|
||||
: null;
|
||||
if (programName != null)
|
||||
partPrograms.Add(programName, part.Program);
|
||||
parts.Add(
|
||||
new PartDto
|
||||
{
|
||||
@@ -216,6 +230,8 @@ namespace OpenNest.IO
|
||||
Rotation = part.Rotation,
|
||||
HasManualLeadIns = part.HasManualLeadIns,
|
||||
LeadInsLocked = part.LeadInsLocked,
|
||||
Program = programName,
|
||||
DrawingHash = programName == null ? null : drawingHashes[match.Key],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -264,6 +280,7 @@ namespace OpenNest.IO
|
||||
Parts = parts,
|
||||
CutOffs = cutoffs,
|
||||
GrainAngle = plate.GrainAngle,
|
||||
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -342,43 +359,44 @@ namespace OpenNest.IO
|
||||
private void WritePrograms(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var name = $"programs/program-{kvp.Key}";
|
||||
var stream = new MemoryStream();
|
||||
WriteDrawing(stream, kvp.Value);
|
||||
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
stream.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
// Write sub-programs if present
|
||||
if (kvp.Value.Program.SubPrograms.Count > 0)
|
||||
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
|
||||
}
|
||||
drawingHashes[kvp.Key] = WriteProgramEntry(
|
||||
zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
|
||||
}
|
||||
|
||||
private void WriteSubPrograms(
|
||||
ZipArchive zipArchive,
|
||||
int drawingId,
|
||||
Dictionary<int, Program> subPrograms
|
||||
)
|
||||
private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
|
||||
{
|
||||
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
|
||||
var text = GetProgramText(program);
|
||||
var subs = GetSubProgramsText(program);
|
||||
WriteTextEntry(zipArchive, name, text);
|
||||
if (subs.Length > 0)
|
||||
WriteTextEntry(zipArchive, name + "-subs", subs);
|
||||
return GetDrawingHash(text, subs);
|
||||
}
|
||||
|
||||
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
|
||||
private static void WriteTextEntry(ZipArchive zipArchive, string name, string text)
|
||||
{
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
// Hash the exact saved text, including holes: a change to either invalidates the part.
|
||||
internal static string GetDrawingHash(string text, string subs) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
|
||||
text.Length.ToString(CultureInfo.InvariantCulture) + ":" + text + subs)));
|
||||
|
||||
/// <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.Value.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
foreach (var code in kvp.Value.Codes)
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
|
||||
WriteProgram(writer, kvp.Value);
|
||||
writer.WriteLine("M99");
|
||||
}
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private void WriteEntities(ZipArchive zipArchive)
|
||||
@@ -402,11 +420,16 @@ namespace OpenNest.IO
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDrawing(Stream stream, Drawing drawing)
|
||||
/// <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
|
||||
foreach (var v in program.Variables.Values)
|
||||
@@ -421,16 +444,11 @@ namespace OpenNest.IO
|
||||
|
||||
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
for (var i = 0; i < drawing.Program.Length; ++i)
|
||||
{
|
||||
var code = drawing.Program[i];
|
||||
foreach (var code in program.Codes)
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
private string FormatCoord(
|
||||
private static string FormatCoord(
|
||||
double value,
|
||||
string axis,
|
||||
Dictionary<string, string> variableRefs
|
||||
@@ -441,7 +459,7 @@ namespace OpenNest.IO
|
||||
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
|
||||
}
|
||||
|
||||
private string GetCodeString(ICode code)
|
||||
private static string GetCodeString(ICode code)
|
||||
{
|
||||
switch (code.Type)
|
||||
{
|
||||
@@ -545,7 +563,7 @@ namespace OpenNest.IO
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string GetLayerString(LayerType layer)
|
||||
private static string GetLayerString(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
|
||||
@@ -96,6 +96,24 @@ public class CIFiberSampleRegressionTests
|
||||
return result;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Post_ReconstructedNest_IsIdenticalAfterSavedLeadInsReload()
|
||||
{
|
||||
var fixture = ResolveFixture();
|
||||
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
|
||||
var nest = LoadAndLeadIn(fixture.Value.Nest);
|
||||
var expected = PostNest(nest);
|
||||
using var stream = new MemoryStream();
|
||||
new NestWriter(nest).Write(stream);
|
||||
stream.Position = 0;
|
||||
var reader = new NestReader(stream);
|
||||
var restored = reader.Read();
|
||||
|
||||
Assert.Empty(reader.Warnings);
|
||||
Assert.All(restored.Plates.SelectMany(plate => plate.Parts), part => Assert.True(part.HasManualLeadIns));
|
||||
Assert.Equal(expected, PostNest(restored));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Post_ReconstructedNest_MatchesSampleCounts()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
// Keep the desktop settings API stable while sharing the mapping with nest persistence.
|
||||
public static class CuttingParametersSerializer
|
||||
{
|
||||
public static class CuttingParametersSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
public static string Serialize(CuttingParameters parameters) =>
|
||||
IO.CuttingParametersSerializer.Serialize(parameters);
|
||||
|
||||
public static string Serialize(CuttingParameters p)
|
||||
{
|
||||
var dto = new CuttingParametersDto
|
||||
{
|
||||
ExternalLeadIn = ToDto(p.ExternalLeadIn),
|
||||
ExternalLeadOut = ToLeadOutDto(p.ExternalLeadOut),
|
||||
InternalLeadIn = ToDto(p.InternalLeadIn),
|
||||
InternalLeadOut = ToLeadOutDto(p.InternalLeadOut),
|
||||
ArcCircleLeadIn = ToDto(p.ArcCircleLeadIn),
|
||||
ArcCircleLeadOut = ToLeadOutDto(p.ArcCircleLeadOut),
|
||||
TabsEnabled = p.TabsEnabled,
|
||||
TabWidth = p.TabConfig?.Size ?? 0.25,
|
||||
PierceClearance = p.PierceClearance,
|
||||
RoundLeadInAngles = p.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = p.LeadInAngleIncrement,
|
||||
AutoTabMinSize = p.AutoTabMinSize,
|
||||
AutoTabMaxSize = p.AutoTabMaxSize,
|
||||
};
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
|
||||
public static CuttingParameters Deserialize(string json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<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; }
|
||||
}
|
||||
}
|
||||
public static CuttingParameters Deserialize(string json) =>
|
||||
IO.CuttingParametersSerializer.Deserialize(json);
|
||||
}
|
||||
|
||||
@@ -891,6 +891,11 @@ namespace OpenNest.Forms
|
||||
if (Nest.Drawings.Count == 0)
|
||||
return;
|
||||
|
||||
// Capture before loading the editor: its conversion can mutate programs in place.
|
||||
var snapshot = DrawingProgramSnapshot.Capture(
|
||||
Nest.Drawings,
|
||||
program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program)
|
||||
);
|
||||
var converter = new CadConverterForm();
|
||||
converter.LoadDrawings(Nest.Drawings);
|
||||
|
||||
@@ -925,11 +930,16 @@ namespace OpenNest.Forms
|
||||
foreach (var d in newByName.Values)
|
||||
Nest.Drawings.Add(d);
|
||||
|
||||
// Refresh all parts to use the updated programs
|
||||
foreach (var plate in Nest.Plates)
|
||||
foreach (var part in plate.Parts)
|
||||
if (!part.BaseDrawing.IsCutOff)
|
||||
part.Update();
|
||||
// Leave unchanged parts' lead-ins, tabs, locks and program instances intact.
|
||||
var updatedParts = snapshot.UpdateChangedParts(Nest.Plates).ToHashSet();
|
||||
foreach (var layoutPart in PlateView.Parts)
|
||||
{
|
||||
if (!updatedParts.Contains(layoutPart.BasePart))
|
||||
continue;
|
||||
|
||||
layoutPart.IsDirty = true;
|
||||
layoutPart.InvalidateOffset();
|
||||
}
|
||||
|
||||
UpdateDrawingList();
|
||||
PlateView.Invalidate();
|
||||
@@ -1064,9 +1074,12 @@ namespace OpenNest.Forms
|
||||
{
|
||||
form.SaveDrawing(drawing);
|
||||
|
||||
foreach (var part in PlateView.Parts)
|
||||
part.Update();
|
||||
// Metadata edits only refresh color; never rebuild the placed part program.
|
||||
foreach (var layoutPart in PlateView.Parts)
|
||||
if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing))
|
||||
layoutPart.Color = drawing.Color;
|
||||
|
||||
UpdateDrawingList();
|
||||
PlateView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +566,9 @@ namespace OpenNest.Forms
|
||||
var reader = new NestReader(dlg.FileName);
|
||||
var nest = reader.Read();
|
||||
LoadNest(nest);
|
||||
if (reader.Warnings.Count > 0)
|
||||
MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
|
||||
"Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ Jobs-only API: engines implement `INestingEngine.Solve(NestJob)`; only `NestJobR
|
||||
|
||||
## File Format
|
||||
|
||||
`.nest` files are ZIP archives: `nest.json` (metadata, plates, drawings, placements), `programs/program-N` (G-code per drawing), optional `entities/`, sub-programs, and cached best-fit data.
|
||||
`.nest` files are ZIP archives containing drawing programs, metadata, plates, and placements. Saved nests retain each part's lead-ins, lead-outs, tab gaps, and locks, plus the plate's cutting settings. Changing a drawing's geometry removes obsolete cutting paths from its parts; name, quantity, and color edits preserve them. See the [file-format reference](docs/nest-file-format.md) for compatibility and recovery behavior.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
|
||||
@@ -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