Files
OpenNest/OpenNest.IO/NestReader.cs
T
aj 74f32f0516 fix(nests): keep etch marks when clearing lead-ins of PEP nests
PEP-converted nests stored etch marks only in each placed part's locked
cutting program; the drawing program held the contour cuts alone. Because
Remove Lead-ins rebuilds a part from its drawing, clearing lead-ins also
erased the marks from every part.

Recover the marks while reading the nest: when a drawing has no scribe
moves and every placement that kept its saved program carries the same
marks, add them to the drawing in its own frame and refresh that drawing's
clean placements. Saved programs are never rewritten, drawings that already
have marks are untouched, and drawings whose saved programs disagree are
left alone rather than guessed at.
2026-10-14 06:58:24 -04:00

508 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public sealed class NestReader
{
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)
{
stream = new FileStream(file, FileMode.Open, FileAccess.Read);
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
}
public NestReader(Stream stream)
{
this.stream = stream;
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
}
public Nest Read()
{
var nestJson = ReadEntry("nest.json");
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count);
var entitySets = ReadEntitySets(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
// Nests converted from PEP kept etch marks only in the placed programs; give them
// back to the drawing so removing lead-ins does not remove the marks too.
DrawingMarkRecovery.Apply(nest);
zipArchive.Dispose();
stream.Close();
return nest;
}
private string ReadEntry(string name)
{
var entry =
zipArchive.GetEntry(name)
?? throw new InvalidDataException($"Nest file is missing required entry '{name}'.");
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
return reader.ReadToEnd();
}
private Dictionary<int, Program> ReadPrograms(int count)
{
var programs = new Dictionary<int, Program>();
for (var i = 1; i <= count; i++)
{
var name = $"programs/program-{i}";
if (zipArchive.GetEntry(name) == null)
continue;
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);
var currentId = (int?)null;
var lines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
{
// Flush previous sub-program
if (currentId.HasValue && lines.Count > 0)
parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
currentId = id;
lines.Clear();
}
else if (trimmed == "M99")
{
if (currentId.HasValue && lines.Count > 0)
parent.SubPrograms[currentId.Value] = ParseSubProgram(lines);
currentId = null;
lines.Clear();
}
else
{
lines.Add(trimmed);
}
}
// Wire up SubProgramCall.Program references
foreach (var code in parent.Codes)
{
if (
code is SubProgramCall call
&& parent.SubPrograms.TryGetValue(call.Id, out var sub)
)
call.Program = sub;
}
}
private static Program ParseSubProgram(List<string> lines)
{
var text = string.Join("\n", lines);
var memStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
var reader = new ProgramReader(memStream);
return reader.Read();
}
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(
int count
)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
if (entry == null)
continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
result[i] = EntitySerializer.FromDto(dto);
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(
NestDto dto,
Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets
)
{
var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings)
{
var drawing = new Drawing(d.Name);
drawing.Customer = d.Customer;
drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B);
drawing.Quantity.Required = d.Quantity.Required;
drawing.Priority = d.Priority;
drawing.Constraints.StepAngle = d.Constraints.StepAngle;
drawing.Constraints.StartAngle = d.Constraints.StartAngle;
drawing.Constraints.EndAngle = d.Constraints.EndAngle;
drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent;
drawing.Material = new Material(
d.Material.Name,
d.Material.Grade,
d.Material.Density
);
drawing.Source.Path = d.Source.Path;
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
if (d.Bends != null)
{
foreach (var b in d.Bends)
{
drawing.Bends.Add(
new Bend
{
StartPoint = new Vector(b.StartX, b.StartY),
EndPoint = new Vector(b.EndX, b.EndY),
Direction = Enum.TryParse<BendDirection>(
b.Direction,
true,
out var dir
)
? dir
: BendDirection.Unknown,
Angle = b.Angle,
Radius = b.Radius,
NoteText = b.NoteText,
}
);
}
}
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
if (entitySets.TryGetValue(d.Id, out var entitySet))
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
// Older files saved etch marks as cut moves; restore their Scribe layer so
// nesting ignores them. The program was mutated in place, so refresh.
if (
drawing.Program != null
&& ScribeLayerRepair.Apply(drawing.Program, drawing.SourceEntities, drawing.Source.Offset) > 0
)
{
drawing.UpdateArea();
drawing.RecomputeCanonicalAngle();
}
}
map[d.Id] = drawing;
}
return map;
}
private void ReadBestFits(Dictionary<int, Drawing> drawingMap)
{
foreach (var kvp in drawingMap)
{
var entry = zipArchive.GetEntry($"bestfits/bestfit-{kvp.Key}");
if (entry == null)
continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var sets = JsonSerializer.Deserialize<List<BestFitSetDto>>(json, JsonOptions);
if (sets == null)
continue;
PopulateBestFitSets(kvp.Value, sets);
}
}
private void PopulateBestFitSets(Drawing drawing, List<BestFitSetDto> sets)
{
foreach (var set in sets)
{
var results = set
.Results.Select(r => new BestFitResult
{
Candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = r.Part1Rotation,
Part2Rotation = r.Part2Rotation,
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
StrategyIndex = r.StrategyType,
TestNumber = r.TestNumber,
Spacing = r.CandidateSpacing,
},
RotatedArea = r.RotatedArea,
BoundingWidth = r.BoundingWidth,
BoundingHeight = r.BoundingHeight,
OptimalRotation = r.OptimalRotation,
Keep = r.Keep,
Reason = r.Reason,
TrueArea = r.TrueArea,
HullAngles = r.HullAngles,
})
.ToList();
BestFitCache.Populate(
drawing,
set.PlateWidth,
set.PlateHeight,
set.Spacing,
results
);
}
}
/// <summary>
/// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail
/// is ignored rather than failing the nest; each plate's saved zones still load). A
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
private static ClampRail ReadClampRail(ClampRailDto dto)
{
if (dto?.Centers == null || dto.Centers.Count == 0)
return null;
try
{
// An invalid removal time (negative or not finite) falls back to the default
// rather than dropping the rail, which would also discard every sheet's
// removed-clamp state.
var minutes = dto.RemovalMinutes is double m && double.IsFinite(m) && m >= 0
? m
: ClampRail.DefaultRemovalMinutes;
return new ClampRail(dto.Width, dto.Depth, dto.Centers, minutes);
}
catch (ArgumentException)
{
return null;
}
}
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
{
var nest = new Nest();
nest.Name = dto.Name;
Units units;
if (Enum.TryParse(dto.Units, true, out units))
nest.Units = units;
nest.Customer = dto.Customer;
nest.DateCreated = DateTime.Parse(dto.DateCreated);
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
nest.Notes = dto.Notes;
nest.AssistGas = dto.AssistGas ?? "";
// Additive v2 fields: absent or unknown values keep the defaults.
nest.Status = Enum.TryParse<NestStatus>(dto.Status, true, out var status)
? status
: NestStatus.Quote;
nest.MadeBy = dto.MadeBy ?? "";
// Nest-level material and thickness (fall back to PlateDefaults for old files)
var pd = dto.PlateDefaults;
var matDto = dto.Material ?? pd.Material;
nest.Thickness = dto.Thickness > 0 ? dto.Thickness : pd.Thickness;
nest.Material = new Material(matDto.Name, matDto.Grade, matDto.Density);
// Plate defaults
nest.PlateDefaults.Size = new OpenNest.Geometry.Size(pd.Size.Width, pd.Size.Length);
nest.PlateDefaults.Quadrant = pd.Quadrant;
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(
pd.EdgeSpacing.Left,
pd.EdgeSpacing.Bottom,
pd.EdgeSpacing.Right,
pd.EdgeSpacing.Top
);
var clampRail = ReadClampRail(pd.ClampRail);
nest.PlateDefaults.ClampRail = clampRail;
// Stock options: sheet sizes and salvage rate
nest.SalvageRate = dto.SalvageRate;
if (dto.PlateOptions != null)
{
nest.PlateOptions = dto
.PlateOptions.Select(o => new PlateOption
{
Width = o.Width,
Length = o.Length,
Cost = o.Cost,
})
.ToList();
}
// Drawings
foreach (var d in drawingMap.OrderBy(k => k.Key))
nest.Drawings.Add(d.Value);
// Plates
foreach (var p in dto.Plates.OrderBy(p => p.Id))
{
var plate = new Plate();
plate.Size = new OpenNest.Geometry.Size(p.Size.Width, p.Size.Length);
plate.Quadrant = p.Quadrant;
plate.Quantity = p.Quantity;
plate.PartSpacing = p.PartSpacing;
plate.EdgeSpacing = new Spacing(
p.EdgeSpacing.Left,
p.EdgeSpacing.Bottom,
p.EdgeSpacing.Right,
p.EdgeSpacing.Top
);
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
// Clamp zones: older files have no key at all; an individually invalid
// entry is dropped rather than poisoning the whole nest.
if (p.Clamps != null)
{
foreach (var c in p.Clamps)
{
try
{
plate.ClampZones.Add(
new ClampZone(c.Id, new OpenNest.Geometry.Box(c.X, c.Y, c.Length, c.Width))
);
}
catch (ArgumentException)
{
// Invalid zone definition; ignore this one entry.
}
}
}
// A plate follows the nest rail only when its saved zones are exactly the
// rail's for this sheet minus any saved removed-clamp ids (C13); otherwise
// the saved zones are kept as static, explicit data.
var removedClamps = p.RemovedClamps?
.Where(id => !string.IsNullOrEmpty(id))
.ToHashSet(StringComparer.Ordinal)
?? new HashSet<string>();
// A sheet saved with its own rail (not the nest default) follows that one.
var plateRail = ReadClampRail(p.ClampRail) ?? clampRail;
if (plateRail != null && plateRail.Matches(plate, removedClamps))
plate.SetClampRail(plateRail, removedClamps);
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);
}
// Cut-offs
if (p.CutOffs != null)
{
var sequence = new Dictionary<CutOff, int>();
foreach (var cutoffDto in p.CutOffs)
{
var axis =
cutoffDto.Axis?.ToLowerInvariant() == "horizontal"
? CutOffAxis.Horizontal
: CutOffAxis.Vertical;
var cutoff = new CutOff(new Vector(cutoffDto.X, cutoffDto.Y), axis)
{
StartLimit = cutoffDto.StartLimit,
EndLimit = cutoffDto.EndLimit,
};
plate.CutOffs.Add(cutoff);
if (cutoffDto.Sequence is int index)
sequence[cutoff] = index;
}
plate.RegenerateCutOffs(new CutOffSettings(), sequence);
}
nest.Plates.Add(plate);
}
return nest;
}
}
}