Files
OpenNest/OpenNest.IO/NestReader.cs
T
ajandClaude Opus 5.5 0ca4e64189 fix(bestfit): drop saved pair sets whose canonical frame changed
Saved best-fit pairs are in the drawing's canonical frame, but the canonical
angle is recomputed on load and was not saved with the set. When that
computation changes (RotatingCalipers checking every hull edge, marks no
longer counted as material), old pairs were replayed in a turned frame and
could overlap.

Each saved set now records canonicalAngle, and BestFitCacheStore.Import
skips sets whose angle no longer matches the recomputed one (1e-9 rad,
modulo a full turn). Sets from older files have no angle and are dropped;
their pairs are recomputed when next needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:04:23 -04:00

676 lines
27 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.BestFit;
using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Support;
using OpenNest.TabbedCutouts;
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 and library links that could not be restored; everything else still loads.</summary>
public IReadOnlyList<string> Warnings => warnings.AsReadOnly();
/// <summary>
/// Receives saved best-fit sets. Without a store, <c>bestfits/</c> entries are skipped
/// unread; they are only a cache.
/// </summary>
public IBestFitStore BestFitStore { get; init; }
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, Dictionary<Guid, EntityRole> roles)> ReadEntitySets(
int count
)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>, Dictionary<Guid, EntityRole>)>();
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);
var (entities, suppressed) = EntitySerializer.FromDto(dto);
result[i] = (entities, suppressed, EntitySerializer.RolesFromDto(dto));
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(
NestDto dto,
Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed, Dictionary<Guid, EntityRole> roles)> 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.HasCustomColor = d.CustomColor;
drawing.Quantity.Required = d.Quantity.Required;
drawing.Priority = d.Priority;
drawing.CutFirst = d.CutFirst;
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;
drawing.EntityRoles = entitySet.roles;
// Older files saved etch marks as cut moves; restore their Scribe layer so
// nesting ignores them. The program was mutated in place, so refresh. Marks
// the user set to cut or display were not etched, so they are not repair sources.
var marks = drawing.SourceEntities.Where(e =>
EntityRoles.Of(e, drawing.EntityRoles) == EntityRole.Etch);
if (
drawing.Program != null
&& ScribeLayerRepair.Apply(drawing.Program, marks, drawing.Source.Offset) > 0
)
{
drawing.UpdateArea();
drawing.RecomputeCanonicalAngle();
}
}
drawing.Library = ReadLibraryLink(d);
drawing.CutPins = ReadCutPins(d);
drawing.Breakoffs = ReadBreakoffs(d);
drawing.TabbedCutouts = ReadTabbedCutouts(d);
map[d.Id] = drawing;
}
return map;
}
// A link that cannot be fully trusted is dropped: the drawing loads as a plain nest
// drawing, which is what it is without a verifiable library revision.
private DrawingLibraryLink ReadLibraryLink(DrawingDto d)
{
var link = d.Library;
if (link == null)
return null;
if (!link.Malformed
&& !string.IsNullOrWhiteSpace(link.ServerKey)
&& link.Id != Guid.Empty
&& link.Revision >= 1
&& TryParseName<CutType>(link.CutType, out var cutType)
&& TryParseName<Units>(link.Units, out var units)
&& double.IsFinite(link.Thickness)
&& link.Thickness >= 0)
{
return new DrawingLibraryLink(link.ServerKey, link.Id, link.Revision, cutType, link.Thickness,
link.MaterialName, link.MaterialGrade, units, link.Notes, link.PackageSha256);
}
warnings.Add($"Drawing '{d.Name}': its drawing library link could not be read; "
+ "loaded it as a nest drawing without a library revision.");
return null;
}
// Pins are independent: one that cannot be trusted is skipped and the rest still apply.
private List<ContourCutPin> ReadCutPins(DrawingDto d)
{
var pins = new List<ContourCutPin>();
if (d.CutPins == null)
return pins;
for (var i = 0; i < d.CutPins.Count; i++)
{
var pin = CutPinFormat.Read(d.CutPins[i], out var problem);
if (pin != null)
pins.Add(pin);
else
warnings.Add($"Drawing '{d.Name}': cut pin {i + 1} could not be read ({problem}); "
+ "skipped it, so that contour uses the nest's cutting settings.");
}
return pins;
}
// The program already has breakoffs applied; a dropped definition only loses editability.
private List<Breakoff> ReadBreakoffs(DrawingDto d)
{
var breakoffs = new List<Breakoff>();
if (d.Breakoffs == null)
return breakoffs;
for (var i = 0; i < d.Breakoffs.Count; i++)
{
var dto = d.Breakoffs[i];
if (dto?.EdgeIds is { Count: > 0 } && dto.TabCenters != null
&& dto.TabWidth > 0 && double.IsFinite(dto.TabWidth)
&& dto.TabCenters.All(double.IsFinite))
{
breakoffs.Add(new Breakoff
{
EdgeIds = dto.EdgeIds.ToList(),
Flip = dto.Flip,
TabWidth = dto.TabWidth,
TabCenters = dto.TabCenters.ToList(),
});
}
else
{
warnings.Add($"Drawing '{d.Name}': breakoff {i + 1} could not be read; its cut geometry "
+ "is kept, but it can no longer be edited as a breakoff.");
}
}
return breakoffs;
}
// Like breakoffs: the program already has the tabs applied, so a dropped definition only
// loses editability.
private List<TabbedCutout> ReadTabbedCutouts(DrawingDto d)
{
var cutouts = new List<TabbedCutout>();
if (d.TabbedCutouts == null)
return cutouts;
for (var i = 0; i < d.TabbedCutouts.Count; i++)
{
var dto = d.TabbedCutouts[i];
if (dto?.EdgeIds is { Count: > 0 } && dto.TabCenters != null
&& dto.TabWidth > 0 && double.IsFinite(dto.TabWidth)
&& dto.TabCenters.All(double.IsFinite))
{
cutouts.Add(new TabbedCutout
{
EdgeIds = dto.EdgeIds.ToList(),
TabWidth = dto.TabWidth,
TabCenters = dto.TabCenters.ToList(),
});
}
else
{
warnings.Add($"Drawing '{d.Name}': tabbed cutout {i + 1} could not be read; its cut "
+ "geometry is kept, but it can no longer be edited as a tabbed cutout.");
}
}
return cutouts;
}
private bool ReadPinsOff(PartDto dto, int plateId, int partIndex, Part part)
{
if (dto.DrawingPinsOff is bool pinsOff)
return pinsOff;
warnings.Add($"Plate {plateId}, part {partIndex} ('{part.BaseDrawing.Name}'): its drawing pins "
+ "setting could not be read; the drawing's cut pins apply.");
return false;
}
// Single enum names only: Enum.TryParse also accepts numbers and comma lists.
private static bool TryParseName<T>(string text, out T value)
where T : struct, Enum
{
value = default;
return !string.IsNullOrEmpty(text)
&& text.All(char.IsAsciiLetter)
&& Enum.TryParse(text, ignoreCase: false, out value)
&& Enum.IsDefined(value);
}
private void ReadBestFits(Dictionary<int, Drawing> drawingMap)
{
if (BestFitStore == null)
return;
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;
BestFitStore.Import(kvp.Value, sets.Select(FromDto).ToList());
}
}
private static SavedPairSet FromDto(BestFitSetDto set) =>
new(
set.PlateWidth,
set.PlateHeight,
set.Spacing,
set.Results.Select(r => new SavedPairResult(
r.Part1Rotation,
r.Part2Rotation,
r.Part2OffsetX,
r.Part2OffsetY,
r.StrategyType,
r.TestNumber,
r.CandidateSpacing,
r.RotatedArea,
r.BoundingWidth,
r.BoundingHeight,
r.OptimalRotation,
r.Keep,
r.Reason,
r.TrueArea,
r.HullAngles
)).ToList(),
set.CanonicalAngle
);
/// <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>
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</summary>
private static SupportBed ReadSupportBed(SupportBedDto dto)
{
if (dto == null)
return null;
try
{
return new SupportGrid(dto.PitchX, dto.PitchY, dto.OffsetX, dto.OffsetY,
dto.RegistrationTolerance, dto.StabilityMargin);
}
catch (ArgumentOutOfRangeException)
{
return null;
}
}
private static ClampRail ReadClampRail(ClampRailDto dto)
{
if (dto == null)
return null;
var clamps = Edge(dto.Centers, ClampEdge.OriginSide)
.Concat(Edge(dto.FarSideCenters, ClampEdge.FarSide))
.Concat(Edge(dto.OriginEndCenters, ClampEdge.OriginEnd))
.Concat(Edge(dto.FarEndCenters, ClampEdge.FarEnd))
.ToList();
if (clamps.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, clamps, minutes);
}
catch (ArgumentException)
{
return null;
}
static IEnumerable<RailClamp> Edge(List<double> centers, ClampEdge edge) =>
centers?.Select(c => new RailClamp(edge, c)) ?? Enumerable.Empty<RailClamp>();
}
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;
// Plates read below follow the nest's bed as they are added.
nest.SetSupportBed(ReadSupportBed(pd.SupportBed));
// 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);
part.DrawingPinsOff = ReadPinsOff(partDto, p.Id, partIndex, part);
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;
}
}
}