Files
OpenNest/OpenNest.IO/NestReader.cs
T
ajandClaude Opus 5.5 f009fab31d feat(ui): make color schemes app-wide and keep only hand-picked drawing colors
- Add Drawing.HasCustomColor, set when a color is picked in Drawing
  Properties or comes from a library record with a stored color.
- Persist it as customColor in .nest files (omitted when false) and keep it
  out of drawing packages; split pieces inherit it.
- Recolor non-custom drawings from the active palette when a nest opens and
  when the scheme changes; custom colors keep their palette slot.
- Library settings store a color only when it is custom.
- Document drawing colors in nest-file-format.md and nesting-view-colors.md.

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

646 lines
26 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.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()
);
/// <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);
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;
}
}
}