mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 15:21:06 -04:00
Omit an empty clamp-carrying plate only when a new plate from the saved defaults would recreate it: same size and quadrant and zones the saved rail recreates. A rail-following empty sheet with another size or quadrant is written again instead of being lost. Read plateDefaults.clampRail through a lenient converter so a wrongly typed rail (a string or null width, non-numeric centers, an array in place of the object) loads as no rail while every plate's saved zones still load, as the file format documents, instead of failing the whole nest. Review findings 4 and 5, Project Memory 4e5f566d.
625 lines
24 KiB
C#
625 lines
24 KiB
C#
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;
|
|
using OpenNest.Engine.BestFit;
|
|
using static OpenNest.IO.NestFormat;
|
|
|
|
namespace OpenNest.IO
|
|
{
|
|
public sealed class NestWriter
|
|
{
|
|
private const int OutputPrecision = 10;
|
|
private const string CoordinateFormat = "0.##########";
|
|
|
|
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)
|
|
{
|
|
this.drawingDict = new Dictionary<int, Drawing>();
|
|
this.nest = nest;
|
|
}
|
|
|
|
public bool Write(string file)
|
|
{
|
|
using var fileStream = new FileStream(file, FileMode.Create);
|
|
return Write(fileStream);
|
|
}
|
|
|
|
public bool Write(Stream stream)
|
|
{
|
|
nest.DateLastModified = DateTime.Now;
|
|
SetDrawingIds();
|
|
|
|
using var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
|
|
|
|
drawingHashes.Clear();
|
|
partPrograms.Clear();
|
|
WritePrograms(zipArchive);
|
|
WriteNestJson(zipArchive);
|
|
foreach (var entry in partPrograms)
|
|
WriteProgramEntry(zipArchive, entry.Key, entry.Value);
|
|
WriteEntities(zipArchive);
|
|
WriteBestFits(zipArchive);
|
|
|
|
return true;
|
|
}
|
|
|
|
private void SetDrawingIds()
|
|
{
|
|
drawingDict.Clear();
|
|
var id = 1;
|
|
foreach (var drawing in nest.Drawings)
|
|
{
|
|
drawingDict.Add(id, drawing);
|
|
id++;
|
|
}
|
|
}
|
|
|
|
private void WriteNestJson(ZipArchive zipArchive)
|
|
{
|
|
var dto = BuildNestDto();
|
|
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
|
|
|
var entry = zipArchive.CreateEntry("nest.json");
|
|
using var stream = entry.Open();
|
|
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
|
writer.Write(json);
|
|
}
|
|
|
|
private NestDto BuildNestDto()
|
|
{
|
|
return new NestDto
|
|
{
|
|
Version = 2,
|
|
Name = nest.Name ?? "",
|
|
Units = nest.Units.ToString(),
|
|
Customer = nest.Customer ?? "",
|
|
DateCreated = nest.DateCreated.ToString("o"),
|
|
DateLastModified = nest.DateLastModified.ToString("o"),
|
|
Notes = nest.Notes ?? "",
|
|
AssistGas = nest.AssistGas ?? "",
|
|
Status = nest.Status.ToString(),
|
|
MadeBy = nest.MadeBy ?? "",
|
|
Thickness = nest.Thickness,
|
|
Material = new MaterialDto
|
|
{
|
|
Name = nest.Material.Name ?? "",
|
|
Grade = nest.Material.Grade ?? "",
|
|
Density = nest.Material.Density,
|
|
},
|
|
PlateDefaults = BuildPlateDefaultsDto(),
|
|
Drawings = BuildDrawingDtos(),
|
|
Plates = BuildPlateDtos(),
|
|
PlateOptions =
|
|
nest.PlateOptions?.Select(o => new PlateOptionDto
|
|
{
|
|
Width = o.Width,
|
|
Length = o.Length,
|
|
Cost = o.Cost,
|
|
})
|
|
.ToList()
|
|
?? new(),
|
|
SalvageRate = nest.SalvageRate,
|
|
};
|
|
}
|
|
|
|
private PlateDefaultsDto BuildPlateDefaultsDto()
|
|
{
|
|
var pd = nest.PlateDefaults;
|
|
return new PlateDefaultsDto
|
|
{
|
|
Size = new SizeDto { Width = pd.Size.Width, Length = pd.Size.Length },
|
|
Thickness = nest.Thickness,
|
|
Quadrant = pd.Quadrant,
|
|
PartSpacing = pd.PartSpacing,
|
|
Material = new MaterialDto
|
|
{
|
|
Name = nest.Material.Name ?? "",
|
|
Grade = nest.Material.Grade ?? "",
|
|
Density = nest.Material.Density,
|
|
},
|
|
EdgeSpacing = new SpacingDto
|
|
{
|
|
Left = pd.EdgeSpacing.Left,
|
|
Top = pd.EdgeSpacing.Top,
|
|
Right = pd.EdgeSpacing.Right,
|
|
Bottom = pd.EdgeSpacing.Bottom,
|
|
},
|
|
ClampRail = pd.ClampRail == null
|
|
? null
|
|
: new ClampRailDto
|
|
{
|
|
Width = pd.ClampRail.Width,
|
|
Depth = pd.ClampRail.Depth,
|
|
Centers = pd.ClampRail.Centers.ToList(),
|
|
},
|
|
};
|
|
}
|
|
|
|
private List<DrawingDto> BuildDrawingDtos()
|
|
{
|
|
var list = new List<DrawingDto>();
|
|
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
|
{
|
|
var d = kvp.Value;
|
|
list.Add(
|
|
new DrawingDto
|
|
{
|
|
Id = kvp.Key,
|
|
Name = d.Name ?? "",
|
|
Customer = d.Customer ?? "",
|
|
Color = new ColorDto
|
|
{
|
|
A = d.Color.A,
|
|
R = d.Color.R,
|
|
G = d.Color.G,
|
|
B = d.Color.B,
|
|
},
|
|
Quantity = new QuantityDto { Required = d.Quantity.Required },
|
|
Priority = d.Priority,
|
|
Constraints = new ConstraintsDto
|
|
{
|
|
StepAngle = d.Constraints.StepAngle,
|
|
StartAngle = d.Constraints.StartAngle,
|
|
EndAngle = d.Constraints.EndAngle,
|
|
Allow180Equivalent = d.Constraints.Allow180Equivalent,
|
|
},
|
|
Material = new MaterialDto
|
|
{
|
|
Name = d.Material.Name ?? "",
|
|
Grade = d.Material.Grade ?? "",
|
|
Density = d.Material.Density,
|
|
},
|
|
Source = new SourceDto
|
|
{
|
|
Path = d.Source.Path ?? "",
|
|
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y },
|
|
},
|
|
Bends =
|
|
d.Bends?.Select(b => new BendDto
|
|
{
|
|
StartX = b.StartPoint.X,
|
|
StartY = b.StartPoint.Y,
|
|
EndX = b.EndPoint.X,
|
|
EndY = b.EndPoint.Y,
|
|
Direction = b.Direction.ToString(),
|
|
Angle = b.Angle,
|
|
Radius = b.Radius,
|
|
NoteText = b.NoteText ?? "",
|
|
})
|
|
.ToList()
|
|
?? new List<BendDto>(),
|
|
}
|
|
);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a plate created from the saved defaults would be this sheet's clamp
|
|
/// configuration: same size and quadrant, and zones the saved rail recreates.
|
|
/// </summary>
|
|
private bool DefaultsRecreateClamps(Plate plate)
|
|
{
|
|
var defaults = nest.PlateDefaults;
|
|
return defaults.ClampRail is { } rail
|
|
&& plate.Size.Width == defaults.Size.Width
|
|
&& plate.Size.Length == defaults.Size.Length
|
|
&& plate.Quadrant == defaults.Quadrant
|
|
&& rail.Matches(plate);
|
|
}
|
|
|
|
private List<PlateDto> BuildPlateDtos()
|
|
{
|
|
var list = new List<PlateDto>();
|
|
var id = 0;
|
|
for (var i = 0; i < nest.Plates.Count; i++)
|
|
{
|
|
var plate = nest.Plates[i];
|
|
|
|
// An empty plate is omitted unless that would lose a clamp configuration the
|
|
// saved defaults cannot recreate; the editor's trailing empty sheet is dropped.
|
|
if (
|
|
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
|
|
&& plate.CutOffs.Count == 0
|
|
&& (plate.ClampZones.Count == 0 || DefaultsRecreateClamps(plate))
|
|
)
|
|
continue;
|
|
|
|
id++;
|
|
var parts = new List<PartDto>();
|
|
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
|
|
{
|
|
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
|
|
{
|
|
DrawingId = match.Key,
|
|
X = part.Location.X,
|
|
Y = part.Location.Y,
|
|
Rotation = part.Rotation,
|
|
HasManualLeadIns = part.HasManualLeadIns,
|
|
LeadInsLocked = part.LeadInsLocked,
|
|
Program = programName,
|
|
DrawingHash = programName == null ? null : drawingHashes[match.Key],
|
|
}
|
|
);
|
|
}
|
|
|
|
var cutoffs = new List<CutOffDto>();
|
|
foreach (var cutoff in plate.CutOffs)
|
|
{
|
|
var sequence = -1;
|
|
for (var j = 0; j < plate.Parts.Count; j++)
|
|
{
|
|
if (ReferenceEquals(plate.Parts[j].BaseDrawing, cutoff.Drawing))
|
|
{
|
|
sequence = j;
|
|
break;
|
|
}
|
|
}
|
|
|
|
cutoffs.Add(
|
|
new CutOffDto
|
|
{
|
|
X = cutoff.Position.X,
|
|
Y = cutoff.Position.Y,
|
|
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
|
|
StartLimit = cutoff.StartLimit,
|
|
EndLimit = cutoff.EndLimit,
|
|
Sequence = sequence >= 0 ? sequence : null,
|
|
}
|
|
);
|
|
}
|
|
|
|
list.Add(
|
|
new PlateDto
|
|
{
|
|
Id = id,
|
|
Size = new SizeDto { Width = plate.Size.Width, Length = plate.Size.Length },
|
|
Quadrant = plate.Quadrant,
|
|
Quantity = plate.Quantity,
|
|
PartSpacing = plate.PartSpacing,
|
|
EdgeSpacing = new SpacingDto
|
|
{
|
|
Left = plate.EdgeSpacing.Left,
|
|
Top = plate.EdgeSpacing.Top,
|
|
Right = plate.EdgeSpacing.Right,
|
|
Bottom = plate.EdgeSpacing.Bottom,
|
|
},
|
|
Parts = parts,
|
|
CutOffs = cutoffs,
|
|
GrainAngle = plate.GrainAngle,
|
|
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
|
|
Clamps = plate.ClampZones
|
|
.Select(z => new ClampZoneDto
|
|
{
|
|
Id = z.Id,
|
|
X = z.Bounds.X,
|
|
Y = z.Bounds.Y,
|
|
Length = z.Bounds.Length,
|
|
Width = z.Bounds.Width,
|
|
})
|
|
.ToList(),
|
|
}
|
|
);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
private List<BestFitSetDto> BuildBestFitDtos(Drawing drawing)
|
|
{
|
|
var allBestFits = BestFitCache.GetAllForDrawing(drawing);
|
|
var sets = new List<BestFitSetDto>();
|
|
|
|
// Only save best-fit sets for plate sizes actually used in this nest.
|
|
var plateSizes = new HashSet<(double, double, double)>();
|
|
foreach (var plate in nest.Plates)
|
|
plateSizes.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing));
|
|
|
|
foreach (var kvp in allBestFits)
|
|
{
|
|
if (
|
|
!plateSizes.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing))
|
|
)
|
|
continue;
|
|
|
|
var results = kvp
|
|
.Value.Where(r => r.Keep)
|
|
.Select(r => new BestFitResultDto
|
|
{
|
|
Part1Rotation = r.Candidate.Part1Rotation,
|
|
Part2Rotation = r.Candidate.Part2Rotation,
|
|
Part2OffsetX = r.Candidate.Part2Offset.X,
|
|
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
|
StrategyType = r.Candidate.StrategyIndex,
|
|
TestNumber = r.Candidate.TestNumber,
|
|
CandidateSpacing = r.Candidate.Spacing,
|
|
RotatedArea = r.RotatedArea,
|
|
BoundingWidth = r.BoundingWidth,
|
|
BoundingHeight = r.BoundingHeight,
|
|
OptimalRotation = r.OptimalRotation,
|
|
Keep = r.Keep,
|
|
Reason = r.Reason ?? "",
|
|
TrueArea = r.TrueArea,
|
|
HullAngles = r.HullAngles ?? new List<double>(),
|
|
})
|
|
.ToList();
|
|
|
|
sets.Add(
|
|
new BestFitSetDto
|
|
{
|
|
PlateWidth = kvp.Key.PlateWidth,
|
|
PlateHeight = kvp.Key.PlateHeight,
|
|
Spacing = kvp.Key.Spacing,
|
|
Results = results,
|
|
}
|
|
);
|
|
}
|
|
|
|
return sets;
|
|
}
|
|
|
|
private void WriteBestFits(ZipArchive zipArchive)
|
|
{
|
|
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
|
{
|
|
var sets = BuildBestFitDtos(kvp.Value);
|
|
if (sets.Count == 0)
|
|
continue;
|
|
|
|
var json = JsonSerializer.Serialize(sets, JsonOptions);
|
|
var entry = zipArchive.CreateEntry($"bestfits/bestfit-{kvp.Key}");
|
|
using var stream = entry.Open();
|
|
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
|
writer.Write(json);
|
|
}
|
|
}
|
|
|
|
private void WritePrograms(ZipArchive zipArchive)
|
|
{
|
|
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
|
drawingHashes[kvp.Key] = WriteProgramEntry(
|
|
zipArchive, $"programs/program-{kvp.Key}", kvp.Value.Program);
|
|
}
|
|
|
|
private static string WriteProgramEntry(ZipArchive zipArchive, string name, Program program)
|
|
{
|
|
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);
|
|
}
|
|
|
|
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}");
|
|
WriteProgram(writer, kvp.Value);
|
|
writer.WriteLine("M99");
|
|
}
|
|
return writer.ToString();
|
|
}
|
|
|
|
private void WriteEntities(ZipArchive zipArchive)
|
|
{
|
|
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
|
{
|
|
var drawing = kvp.Value;
|
|
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
|
|
continue;
|
|
|
|
var dto = EntitySerializer.ToDto(
|
|
drawing.SourceEntities,
|
|
drawing.SuppressedEntityIds
|
|
);
|
|
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
|
|
|
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
|
|
using var stream = entry.Open();
|
|
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
|
writer.Write(json);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
|
|
// Emit variable definitions before G-code
|
|
foreach (var v in program.Variables.Values)
|
|
{
|
|
var line = $"{v.Name} = {v.Expression}";
|
|
if (v.Inline)
|
|
line += " inline";
|
|
if (v.Global)
|
|
line += " global";
|
|
writer.WriteLine(line);
|
|
}
|
|
|
|
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
|
|
|
|
foreach (var code in program.Codes)
|
|
writer.WriteLine(GetCodeString(code));
|
|
}
|
|
|
|
private static string FormatCoord(
|
|
double value,
|
|
string axis,
|
|
Dictionary<string, string> variableRefs
|
|
)
|
|
{
|
|
if (variableRefs != null && variableRefs.TryGetValue(axis, out var varName))
|
|
return $"${varName}";
|
|
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
|
|
}
|
|
|
|
private static string GetCodeString(ICode code)
|
|
{
|
|
switch (code.Type)
|
|
{
|
|
case CodeType.ArcMove:
|
|
{
|
|
var sb = new StringBuilder();
|
|
var arcMove = (ArcMove)code;
|
|
var refs = arcMove.VariableRefs;
|
|
|
|
var x = FormatCoord(arcMove.EndPoint.X, "X", refs);
|
|
var y = FormatCoord(arcMove.EndPoint.Y, "Y", refs);
|
|
var i = FormatCoord(arcMove.CenterPoint.X, "I", refs);
|
|
var j = FormatCoord(arcMove.CenterPoint.Y, "J", refs);
|
|
|
|
sb.Append(
|
|
arcMove.Rotation == RotationType.CW
|
|
? $"G02X{x}Y{y}I{i}J{j}"
|
|
: $"G03X{x}Y{y}I{i}J{j}"
|
|
);
|
|
|
|
if (arcMove.Layer != LayerType.Cut)
|
|
sb.Append(GetLayerString(arcMove.Layer));
|
|
|
|
if (arcMove.Suppressed)
|
|
sb.Append(":SUPPRESSED");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
case CodeType.Comment:
|
|
{
|
|
var comment = (Comment)code;
|
|
return ":" + comment.Value;
|
|
}
|
|
|
|
case CodeType.LinearMove:
|
|
{
|
|
var sb = new StringBuilder();
|
|
var linearMove = (LinearMove)code;
|
|
var refs = linearMove.VariableRefs;
|
|
|
|
sb.Append(
|
|
$"G01X{FormatCoord(linearMove.EndPoint.X, "X", refs)}Y{FormatCoord(linearMove.EndPoint.Y, "Y", refs)}"
|
|
);
|
|
|
|
if (linearMove.Layer != LayerType.Cut)
|
|
sb.Append(GetLayerString(linearMove.Layer));
|
|
|
|
if (linearMove.Suppressed)
|
|
sb.Append(":SUPPRESSED");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
case CodeType.RapidMove:
|
|
{
|
|
var rapidMove = (RapidMove)code;
|
|
var refs = rapidMove.VariableRefs;
|
|
|
|
return $"G00X{FormatCoord(rapidMove.EndPoint.X, "X", refs)}Y{FormatCoord(rapidMove.EndPoint.Y, "Y", refs)}";
|
|
}
|
|
|
|
case CodeType.SetFeedrate:
|
|
{
|
|
var setFeedrate = (Feedrate)code;
|
|
if (setFeedrate.VariableRef != null)
|
|
return $"F${setFeedrate.VariableRef}";
|
|
return "F" + setFeedrate.Value;
|
|
}
|
|
|
|
case CodeType.SetKerf:
|
|
{
|
|
var setKerf = (Kerf)code;
|
|
|
|
switch (setKerf.Value)
|
|
{
|
|
case KerfType.None:
|
|
return "G40";
|
|
case KerfType.Left:
|
|
return "G41";
|
|
case KerfType.Right:
|
|
return "G42";
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case CodeType.SubProgramCall:
|
|
{
|
|
var subProgramCall = (SubProgramCall)code;
|
|
var x = System
|
|
.Math.Round(subProgramCall.Offset.X, OutputPrecision)
|
|
.ToString(CoordinateFormat);
|
|
var y = System
|
|
.Math.Round(subProgramCall.Offset.Y, OutputPrecision)
|
|
.ToString(CoordinateFormat);
|
|
return $"G65P{subProgramCall.Id}X{x}Y{y}";
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private static string GetLayerString(LayerType layer)
|
|
{
|
|
switch (layer)
|
|
{
|
|
case LayerType.Display:
|
|
return ":DISPLAY";
|
|
|
|
case LayerType.Leadin:
|
|
return ":LEADIN";
|
|
|
|
case LayerType.Leadout:
|
|
return ":LEADOUT";
|
|
|
|
case LayerType.Scribe:
|
|
return ":SCRIBE";
|
|
|
|
default:
|
|
return string.Empty;
|
|
}
|
|
}
|
|
}
|
|
}
|