From c2534ef08b9529623c60f1ab48e4900626d14d21 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 12 Mar 2026 18:44:43 -0400 Subject: [PATCH] feat: replace XML nest file format with JSON (v2) Replace three separate XML metadata files (info, drawing-info, plate-info) and per-plate G-code placement files with a single nest.json inside the ZIP archive. Programs remain as G-code text under a programs/ folder. This eliminates ~400 lines of hand-written XML read/write code and fragile ID-based dictionary linking. Now uses System.Text.Json with DTO records for clean serialization. Also adds Priority and Constraints fields to drawing serialization (previously omitted). Co-Authored-By: Claude Opus 4.6 --- OpenNest.IO/NestFormat.cs | 126 +++ OpenNest.IO/NestReader.cs | 535 +++--------- OpenNest.IO/NestWriter.cs | 365 +++------ .../plans/2026-03-12-nest-file-format-v2.md | 767 ++++++++++++++++++ .../2026-03-12-nest-file-format-v2-design.md | 134 +++ 5 files changed, 1268 insertions(+), 659 deletions(-) create mode 100644 OpenNest.IO/NestFormat.cs create mode 100644 docs/superpowers/plans/2026-03-12-nest-file-format-v2.md create mode 100644 docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs new file mode 100644 index 0000000..393fe00 --- /dev/null +++ b/OpenNest.IO/NestFormat.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace OpenNest.IO +{ + public static class NestFormat + { + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public record NestDto + { + public int Version { get; init; } = 2; + public string Name { get; init; } = ""; + public string Units { get; init; } = "Inches"; + public string Customer { get; init; } = ""; + public string DateCreated { get; init; } = ""; + public string DateLastModified { get; init; } = ""; + public string Notes { get; init; } = ""; + public PlateDefaultsDto PlateDefaults { get; init; } = new(); + public List Drawings { get; init; } = new(); + public List Plates { get; init; } = new(); + } + + public record PlateDefaultsDto + { + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + } + + public record DrawingDto + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Customer { get; init; } = ""; + public ColorDto Color { get; init; } = new(); + public QuantityDto Quantity { get; init; } = new(); + public int Priority { get; init; } + public ConstraintsDto Constraints { get; init; } = new(); + public MaterialDto Material { get; init; } = new(); + public SourceDto Source { get; init; } = new(); + } + + public record PlateDto + { + public int Id { get; init; } + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public int Quantity { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + public List Parts { get; init; } = new(); + } + + public record PartDto + { + public int DrawingId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Rotation { get; init; } + } + + public record SizeDto + { + public double Width { get; init; } + public double Height { get; init; } + } + + public record MaterialDto + { + public string Name { get; init; } = ""; + public string Grade { get; init; } = ""; + public double Density { get; init; } + } + + public record SpacingDto + { + public double Left { get; init; } + public double Top { get; init; } + public double Right { get; init; } + public double Bottom { get; init; } + } + + public record ColorDto + { + public int A { get; init; } = 255; + public int R { get; init; } + public int G { get; init; } + public int B { get; init; } + } + + public record QuantityDto + { + public int Required { get; init; } + } + + public record ConstraintsDto + { + public double StepAngle { get; init; } + public double StartAngle { get; init; } + public double EndAngle { get; init; } + public bool Allow180Equivalent { get; init; } + } + + public record SourceDto + { + public string Path { get; init; } = ""; + public OffsetDto Offset { get; init; } = new(); + } + + public record OffsetDto + { + public double X { get; init; } + public double Y { get; init; } + } + } +} diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index e498bf1..a6b9745 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -1,45 +1,28 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.IO; using System.IO.Compression; using System.Linq; -using System.Text.RegularExpressions; -using System.Xml; +using System.Text.Json; using OpenNest.CNC; using OpenNest.Geometry; -using OpenNest.Math; +using static OpenNest.IO.NestFormat; namespace OpenNest.IO { public sealed class NestReader { - private ZipArchive zipArchive; - private Dictionary plateDict; - private Dictionary drawingDict; - private Dictionary programDict; - private Dictionary plateProgramDict; - private Stream stream; - private Nest nest; - - private NestReader() - { - plateDict = new Dictionary(); - drawingDict = new Dictionary(); - programDict = new Dictionary(); - plateProgramDict = new Dictionary(); - nest = new Nest(); - } + private readonly Stream stream; + private readonly ZipArchive zipArchive; public NestReader(string file) - : this() { stream = new FileStream(file, FileMode.Open, FileAccess.Read); zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); } public NestReader(Stream stream) - : this() { this.stream = stream; zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); @@ -47,52 +30,12 @@ namespace OpenNest.IO public Nest Read() { - const string plateExtensionPattern = "plate-\\d\\d\\d"; - const string programExtensionPattern = "program-\\d\\d\\d"; + var nestJson = ReadEntry("nest.json"); + var dto = JsonSerializer.Deserialize(nestJson, JsonOptions); - foreach (var entry in zipArchive.Entries) - { - var memstream = new MemoryStream(); - using (var entryStream = entry.Open()) - { - entryStream.CopyTo(memstream); - } - - memstream.Position = 0; - - switch (entry.FullName) - { - case "info": - ReadNestInfo(memstream); - continue; - - case "drawing-info": - ReadDrawingInfo(memstream); - continue; - - case "plate-info": - ReadPlateInfo(memstream); - continue; - } - - if (Regex.IsMatch(entry.FullName, programExtensionPattern)) - { - ReadProgram(memstream, entry.FullName); - continue; - } - - if (Regex.IsMatch(entry.FullName, plateExtensionPattern)) - { - ReadPlate(memstream, entry.FullName); - continue; - } - } - - LinkProgramsToDrawings(); - LinkPartsToPlates(); - - AddPlatesToNest(); - AddDrawingsToNest(); + var programs = ReadPrograms(dto.Drawings.Count); + var drawingMap = BuildDrawings(dto, programs); + var nest = BuildNest(dto, drawingMap); zipArchive.Dispose(); stream.Close(); @@ -100,374 +43,114 @@ namespace OpenNest.IO return nest; } - private void ReadNestInfo(Stream stream) + private string ReadEntry(string name) { - var reader = XmlReader.Create(stream); - var spacing = new Spacing(); + 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(); + } - while (reader.Read()) + private Dictionary ReadPrograms(int count) + { + var programs = new Dictionary(); + for (var i = 1; i <= count; i++) { - if (!reader.IsStartElement()) - continue; + var entry = zipArchive.GetEntry($"programs/program-{i}"); + if (entry == null) continue; - switch (reader.Name) + using var entryStream = entry.Open(); + var memStream = new MemoryStream(); + entryStream.CopyTo(memStream); + memStream.Position = 0; + + var reader = new ProgramReader(memStream); + programs[i] = reader.Read(); + } + return programs; + } + + private Dictionary BuildDrawings(NestDto dto, Dictionary programs) + { + var map = new Dictionary(); + 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 (programs.TryGetValue(d.Id, out var pgm)) + drawing.Program = pgm; + + map[d.Id] = drawing; + } + return map; + } + + private Nest BuildNest(NestDto dto, Dictionary 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; + + // Plate defaults + var pd = dto.PlateDefaults; + nest.PlateDefaults.Size = new OpenNest.Geometry.Size(pd.Size.Width, pd.Size.Height); + nest.PlateDefaults.Thickness = pd.Thickness; + nest.PlateDefaults.Quadrant = pd.Quadrant; + nest.PlateDefaults.PartSpacing = pd.PartSpacing; + nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density); + nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top); + + // 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.Height); + plate.Thickness = p.Thickness; + plate.Quadrant = p.Quadrant; + plate.Quantity = p.Quantity; + plate.PartSpacing = p.PartSpacing; + plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density); + plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top); + + foreach (var partDto in p.Parts) { - case "Nest": - nest.Name = reader["name"]; - break; + if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg)) + continue; - case "Units": - Units units; - TryParseEnum(reader.ReadString(), out units); - nest.Units = units; - break; - - case "Customer": - nest.Customer = reader.ReadString(); - break; - - case "DateCreated": - nest.DateCreated = DateTime.Parse(reader.ReadString()); - break; - - case "DateLastModified": - nest.DateLastModified = DateTime.Parse(reader.ReadString()); - break; - - case "Notes": - nest.Notes = Uri.UnescapeDataString(reader.ReadString()); - break; - - case "Size": - nest.PlateDefaults.Size = OpenNest.Geometry.Size.Parse(reader.ReadString()); - break; - - case "Thickness": - nest.PlateDefaults.Thickness = double.Parse(reader.ReadString()); - break; - - case "Quadrant": - nest.PlateDefaults.Quadrant = int.Parse(reader.ReadString()); - break; - - case "PartSpacing": - nest.PlateDefaults.PartSpacing = double.Parse(reader.ReadString()); - break; - - case "Name": - nest.PlateDefaults.Material.Name = reader.ReadString(); - break; - - case "Grade": - nest.PlateDefaults.Material.Grade = reader.ReadString(); - break; - - case "Density": - nest.PlateDefaults.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Left": - spacing.Left = double.Parse(reader.ReadString()); - break; - - case "Right": - spacing.Right = double.Parse(reader.ReadString()); - break; - - case "Top": - spacing.Top = double.Parse(reader.ReadString()); - break; - - case "Bottom": - spacing.Bottom = double.Parse(reader.ReadString()); - break; + var part = new Part(dwg); + part.Rotate(partDto.Rotation); + part.Offset(new Vector(partDto.X, partDto.Y)); + plate.Parts.Add(part); } + + nest.Plates.Add(plate); } - reader.Close(); - nest.PlateDefaults.EdgeSpacing = spacing; - } - - private void ReadDrawingInfo(Stream stream) - { - var reader = XmlReader.Create(stream); - Drawing drawing = null; - - while (reader.Read()) - { - if (!reader.IsStartElement()) - continue; - - switch (reader.Name) - { - case "Drawing": - var id = int.Parse(reader["id"]); - var name = reader["name"]; - - drawingDict.Add(id, (drawing = new Drawing(name))); - break; - - case "Customer": - drawing.Customer = reader.ReadString(); - break; - - case "Color": - { - var parts = reader.ReadString().Split(','); - - if (parts.Length == 3) - { - byte r = byte.Parse(parts[0]); - byte g = byte.Parse(parts[1]); - byte b = byte.Parse(parts[2]); - - drawing.Color = Color.FromArgb(r, g, b); - } - else if (parts.Length == 4) - { - byte a = byte.Parse(parts[0]); - byte r = byte.Parse(parts[1]); - byte g = byte.Parse(parts[2]); - byte b = byte.Parse(parts[3]); - - drawing.Color = Color.FromArgb(a, r, g, b); - } - } - break; - - case "Required": - drawing.Quantity.Required = int.Parse(reader.ReadString()); - break; - - case "Name": - drawing.Material.Name = reader.ReadString(); - break; - - case "Grade": - drawing.Material.Grade = reader.ReadString(); - break; - - case "Density": - drawing.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Path": - drawing.Source.Path = reader.ReadString(); - break; - - case "Offset": - { - var parts = reader.ReadString().Split(','); - - if (parts.Length != 2) - continue; - - drawing.Source.Offset = new Vector(double.Parse(parts[0]), double.Parse(parts[1])); - } - break; - } - } - - reader.Close(); - } - - private void ReadPlateInfo(Stream stream) - { - var reader = XmlReader.Create(stream); - var spacing = new Spacing(); - Plate plate = null; - - while (reader.Read()) - { - if (!reader.IsStartElement()) - continue; - - switch (reader.Name) - { - case "Plate": - var id = int.Parse(reader["id"]); - - if (plate != null) - plate.EdgeSpacing = spacing; - - plateDict.Add(id, (plate = new Plate())); - break; - - case "Size": - plate.Size = OpenNest.Geometry.Size.Parse(reader.ReadString()); - break; - - case "Qty": - plate.Quantity = int.Parse(reader.ReadString()); - break; - - case "Thickness": - plate.Thickness = double.Parse(reader.ReadString()); - break; - - case "Quadrant": - plate.Quadrant = int.Parse(reader.ReadString()); - break; - - case "PartSpacing": - plate.PartSpacing = double.Parse(reader.ReadString()); - break; - - case "Name": - plate.Material.Name = reader.ReadString(); - break; - - case "Grade": - plate.Material.Grade = reader.ReadString(); - break; - - case "Density": - plate.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Left": - spacing.Left = double.Parse(reader.ReadString()); - break; - - case "Right": - spacing.Right = double.Parse(reader.ReadString()); - break; - - case "Top": - spacing.Top = double.Parse(reader.ReadString()); - break; - - case "Bottom": - spacing.Bottom = double.Parse(reader.ReadString()); - break; - } - } - - if (plate != null) - plate.EdgeSpacing = spacing; - } - - private void ReadProgram(Stream stream, string name) - { - var id = GetProgramId(name); - var reader = new ProgramReader(stream); - var pgm = reader.Read(); - programDict.Add(id, pgm); - } - - private void ReadPlate(Stream stream, string name) - { - var id = GetPlateId(name); - var reader = new ProgramReader(stream); - var pgm = reader.Read(); - plateProgramDict.Add(id, pgm); - } - - private void LinkProgramsToDrawings() - { - foreach (var drawingItem in drawingDict) - { - Program pgm; - - if (programDict.TryGetValue(drawingItem.Key, out pgm)) - drawingItem.Value.Program = pgm; - } - } - - private void LinkPartsToPlates() - { - foreach (var plateProgram in plateProgramDict) - { - var parts = CreateParts(plateProgram.Value); - - Plate plate; - - if (!plateDict.TryGetValue(plateProgram.Key, out plate)) - plate = new Plate(); - - plate.Parts.AddRange(parts); - plateDict[plateProgram.Key] = plate; - } - } - - private void AddPlatesToNest() - { - var plates = plateDict.OrderBy(i => i.Key).Select(i => i.Value).ToList(); - nest.Plates.AddRange(plates); - } - - private void AddDrawingsToNest() - { - var drawings = drawingDict.OrderBy(i => i.Key).Select(i => i.Value).ToList(); - drawings.ForEach(d => nest.Drawings.Add(d)); - } - - private List CreateParts(Program pgm) - { - var parts = new List(); - var pos = Vector.Zero; - - for (int i = 0; i < pgm.Codes.Count; i++) - { - var code = pgm.Codes[i]; - - switch (code.Type) - { - case CodeType.RapidMove: - pos = ((RapidMove)code).EndPoint; - break; - - case CodeType.SubProgramCall: - var subpgm = (SubProgramCall)code; - var dwg = drawingDict[subpgm.Id]; - var part = new Part(dwg); - part.Rotate(Angle.ToRadians(subpgm.Rotation)); - part.Offset(pos); - parts.Add(part); - break; - } - } - - return parts; - } - - private int GetPlateId(string name) - { - return int.Parse(name.Replace("plate-", "")); - } - - private int GetProgramId(string name) - { - return int.Parse(name.Replace("program-", "")); - } - - public static T ParseEnum(string value) - { - return (T)Enum.Parse(typeof(T), value, true); - } - - public static bool TryParseEnum(string value, out T e) - { - try - { - e = ParseEnum(value); - return true; - } - catch - { - e = ParseEnum(typeof(T).GetEnumValues().GetValue(0).ToString()); - } - - return false; - } - - private enum NestInfoSection - { - None, - DefaultPlate, - Material, - EdgeSpacing, - Source + return nest; } } } diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 15f6f09..e7d6de0 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -1,32 +1,22 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; -using System.Xml; +using System.Text.Json; using OpenNest.CNC; using OpenNest.Math; +using static OpenNest.IO.NestFormat; namespace OpenNest.IO { public sealed class NestWriter { - /// - /// Number of decimal places the output is round to. - /// This number must have more decimal places than Tolerance.Epsilon - /// private const int OutputPrecision = 10; - - /// - /// Fixed-point format string that avoids scientific notation. - /// ProgramReader treats 'E' as a code letter, so "6.66E-08" would be - /// split into X:"6.66" and E:"-08", corrupting the parsed value. - /// private const string CoordinateFormat = "0.##########"; private readonly Nest nest; - private ZipArchive zipArchive; private Dictionary drawingDict; public NestWriter(Nest nest) @@ -37,27 +27,21 @@ namespace OpenNest.IO public bool Write(string file) { - this.nest.DateLastModified = DateTime.Now; - + nest.DateLastModified = DateTime.Now; SetDrawingIds(); - using (var fileStream = new FileStream(file, FileMode.Create)) - using (zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create)) - { - AddNestInfo(); - AddPlates(); - AddPlateInfo(); - AddDrawings(); - AddDrawingInfo(); - } + using var fileStream = new FileStream(file, FileMode.Create); + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create); + + WriteNestJson(zipArchive); + WritePrograms(zipArchive); return true; } private void SetDrawingIds() { - int id = 1; - + var id = 1; foreach (var drawing in nest.Drawings) { drawingDict.Add(id, drawing); @@ -65,241 +49,156 @@ namespace OpenNest.IO } } - private void AddNestInfo() + private void WriteNestJson(ZipArchive zipArchive) { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() - { - Indent = true - }); + var dto = BuildNestDto(); + var json = JsonSerializer.Serialize(dto, JsonOptions); - writer.WriteStartDocument(); - writer.WriteStartElement("Nest"); - writer.WriteAttributeString("name", nest.Name); - - writer.WriteElementString("Units", nest.Units.ToString()); - writer.WriteElementString("Customer", nest.Customer); - writer.WriteElementString("DateCreated", nest.DateCreated.ToString()); - writer.WriteElementString("DateLastModified", nest.DateLastModified.ToString()); - - writer.WriteStartElement("DefaultPlate"); - writer.WriteElementString("Size", nest.PlateDefaults.Size.ToString()); - writer.WriteElementString("Thickness", nest.PlateDefaults.Thickness.ToString()); - writer.WriteElementString("Quadrant", nest.PlateDefaults.Quadrant.ToString()); - writer.WriteElementString("PartSpacing", nest.PlateDefaults.PartSpacing.ToString()); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", nest.PlateDefaults.Material.Name); - writer.WriteElementString("Grade", nest.PlateDefaults.Material.Grade); - writer.WriteElementString("Density", nest.PlateDefaults.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("EdgeSpacing"); - writer.WriteElementString("Left", nest.PlateDefaults.EdgeSpacing.Left.ToString()); - writer.WriteElementString("Top", nest.PlateDefaults.EdgeSpacing.Top.ToString()); - writer.WriteElementString("Right", nest.PlateDefaults.EdgeSpacing.Right.ToString()); - writer.WriteElementString("Bottom", nest.PlateDefaults.EdgeSpacing.Bottom.ToString()); - writer.WriteEndElement(); - - writer.WriteElementString("Notes", Uri.EscapeDataString(nest.Notes)); - - writer.WriteEndElement(); // DefaultPlate - writer.WriteEndElement(); // Nest - - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("info"); - using (var entryStream = entry.Open()) - { - stream.CopyTo(entryStream); - } + var entry = zipArchive.CreateEntry("nest.json"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); } - private void AddPlates() + private NestDto BuildNestDto() { - int num = 1; - - foreach (var plate in nest.Plates) + return new NestDto { - var stream = new MemoryStream(); - var name = string.Format("plate-{0}", num.ToString().PadLeft(3, '0')); + 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 ?? "", + PlateDefaults = BuildPlateDefaultsDto(), + Drawings = BuildDrawingDtos(), + Plates = BuildPlateDtos() + }; + } - WritePlate(stream, plate); - - var entry = zipArchive.CreateEntry(name); - using (var entryStream = entry.Open()) + private PlateDefaultsDto BuildPlateDefaultsDto() + { + var pd = nest.PlateDefaults; + return new PlateDefaultsDto + { + Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height }, + Thickness = pd.Thickness, + Quadrant = pd.Quadrant, + PartSpacing = pd.PartSpacing, + Material = new MaterialDto { - stream.CopyTo(entryStream); + Name = pd.Material.Name ?? "", + Grade = pd.Material.Grade ?? "", + Density = pd.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = pd.EdgeSpacing.Left, + Top = pd.EdgeSpacing.Top, + Right = pd.EdgeSpacing.Right, + Bottom = pd.EdgeSpacing.Bottom } - - num++; - } + }; } - private void AddPlateInfo() + private List BuildDrawingDtos() { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() + var list = new List(); + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - Indent = true - }); + 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 } + } + }); + } + return list; + } - writer.WriteStartDocument(); - writer.WriteStartElement("Plates"); - writer.WriteAttributeString("count", nest.Plates.Count.ToString()); - - for (int i = 0; i < nest.Plates.Count; ++i) + private List BuildPlateDtos() + { + var list = new List(); + for (var i = 0; i < nest.Plates.Count; i++) { var plate = nest.Plates[i]; - - writer.WriteStartElement("Plate"); - writer.WriteAttributeString("id", (i + 1).ToString()); - - writer.WriteElementString("Quadrant", plate.Quadrant.ToString()); - writer.WriteElementString("Thickness", plate.Thickness.ToString()); - writer.WriteElementString("Size", plate.Size.ToString()); - writer.WriteElementString("Qty", plate.Quantity.ToString()); - writer.WriteElementString("PartSpacing", plate.PartSpacing.ToString()); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", plate.Material.Name); - writer.WriteElementString("Grade", plate.Material.Grade); - writer.WriteElementString("Density", plate.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("EdgeSpacing"); - writer.WriteElementString("Left", plate.EdgeSpacing.Left.ToString()); - writer.WriteElementString("Top", plate.EdgeSpacing.Top.ToString()); - writer.WriteElementString("Right", plate.EdgeSpacing.Right.ToString()); - writer.WriteElementString("Bottom", plate.EdgeSpacing.Bottom.ToString()); - writer.WriteEndElement(); - - writer.WriteEndElement(); // Plate - writer.Flush(); - } - - writer.WriteEndElement(); // Plates - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("plate-info"); - using (var entryStream = entry.Open()) - { - stream.CopyTo(entryStream); - } - } - - private void AddDrawings() - { - int num = 1; - - foreach (var dwg in nest.Drawings) - { - var stream = new MemoryStream(); - var name = string.Format("program-{0}", num.ToString().PadLeft(3, '0')); - - WriteDrawing(stream, dwg); - - var entry = zipArchive.CreateEntry(name); - using (var entryStream = entry.Open()) + var parts = new List(); + foreach (var part in plate.Parts) { - stream.CopyTo(entryStream); + var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); + parts.Add(new PartDto + { + DrawingId = match.Key, + X = part.Location.X, + Y = part.Location.Y, + Rotation = part.Rotation + }); } - num++; + list.Add(new PlateDto + { + Id = i + 1, + Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height }, + Thickness = plate.Thickness, + Quadrant = plate.Quadrant, + Quantity = plate.Quantity, + PartSpacing = plate.PartSpacing, + Material = new MaterialDto + { + Name = plate.Material.Name ?? "", + Grade = plate.Material.Grade ?? "", + Density = plate.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = plate.EdgeSpacing.Left, + Top = plate.EdgeSpacing.Top, + Right = plate.EdgeSpacing.Right, + Bottom = plate.EdgeSpacing.Bottom + }, + Parts = parts + }); } + return list; } - private void AddDrawingInfo() + private void WritePrograms(ZipArchive zipArchive) { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - Indent = true - }); + var name = $"programs/program-{kvp.Key}"; + var stream = new MemoryStream(); + WriteDrawing(stream, kvp.Value); - writer.WriteStartDocument(); - writer.WriteStartElement("Drawings"); - writer.WriteAttributeString("count", nest.Drawings.Count.ToString()); - - int id = 1; - - foreach (var drawing in nest.Drawings) - { - writer.WriteStartElement("Drawing"); - writer.WriteAttributeString("id", id.ToString()); - writer.WriteAttributeString("name", drawing.Name); - - writer.WriteElementString("Customer", drawing.Customer); - writer.WriteElementString("Color", string.Format("{0}, {1}, {2}, {3}", drawing.Color.A, drawing.Color.R, drawing.Color.G, drawing.Color.B)); - - writer.WriteStartElement("Quantity"); - writer.WriteElementString("Required", drawing.Quantity.Required.ToString()); - writer.WriteElementString("Nested", drawing.Quantity.Nested.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", drawing.Material.Name); - writer.WriteElementString("Grade", drawing.Material.Grade); - writer.WriteElementString("Density", drawing.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("Source"); - writer.WriteElementString("Path", drawing.Source.Path); - writer.WriteElementString("Offset", string.Format("{0}, {1}", - drawing.Source.Offset.X, - drawing.Source.Offset.Y)); - writer.WriteEndElement(); // Source - - writer.WriteEndElement(); // Drawing - - id++; - } - - writer.WriteEndElement(); // Drawings - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("drawing-info"); - using (var entryStream = entry.Open()) - { + var entry = zipArchive.CreateEntry(name); + using var entryStream = entry.Open(); stream.CopyTo(entryStream); } } - private void WritePlate(Stream stream, Plate plate) - { - var writer = new StreamWriter(stream); - writer.AutoFlush = true; - writer.WriteLine("G90"); - - foreach (var part in plate.Parts) - { - var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); - var id = match.Key; - - writer.WriteLine("G00X{0}Y{1}", - part.Location.X.ToString(CoordinateFormat), - part.Location.Y.ToString(CoordinateFormat)); - writer.WriteLine("G65P{0}R{1}", id, Angle.ToDegrees(part.Rotation)); - } - - stream.Position = 0; - } - private void WriteDrawing(Stream stream, Drawing drawing) { var program = drawing.Program; @@ -308,7 +207,7 @@ namespace OpenNest.IO writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); - for (int i = 0; i < drawing.Program.Length; ++i) + for (var i = 0; i < drawing.Program.Length; ++i) { var code = drawing.Program[i]; writer.WriteLine(GetCodeString(code)); diff --git a/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md b/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md new file mode 100644 index 0000000..21a371a --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md @@ -0,0 +1,767 @@ +# Nest File Format v2 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the XML+G-code nest file format with a single `nest.json` metadata file plus `programs/` folder inside the ZIP archive. + +**Architecture:** Add a `NestFormat` static class containing DTO records and shared JSON options. Rewrite `NestWriter` to serialize DTOs to JSON and write programs under `programs/`. Rewrite `NestReader` to deserialize JSON and read programs from `programs/`. Public API unchanged. + +**Tech Stack:** `System.Text.Json` (built into .NET 8, no new packages needed) + +**Spec:** `docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.IO/NestFormat.cs` | DTO records for JSON serialization + shared `JsonSerializerOptions` | +| Rewrite | `OpenNest.IO/NestWriter.cs` | Serialize nest to JSON + write programs to `programs/` folder | +| Rewrite | `OpenNest.IO/NestReader.cs` | Deserialize JSON + read programs from `programs/` folder | + +No other files change. `ProgramReader.cs`, `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs`, all domain model classes, and all caller sites remain untouched. + +--- + +## Chunk 1: DTO Records and JSON Options + +### Task 1: Create NestFormat.cs with DTO records + +**Files:** +- Create: `OpenNest.IO/NestFormat.cs` + +These DTOs are the JSON shape — flat records that map 1:1 with the spec's JSON schema. They live in `OpenNest.IO` because they're serialization concerns, not domain model. + +- [ ] **Step 1: Create `NestFormat.cs`** + +```csharp +using System.Text.Json; + +namespace OpenNest.IO +{ + public static class NestFormat + { + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public record NestDto + { + public int Version { get; init; } = 2; + public string Name { get; init; } = ""; + public string Units { get; init; } = "Inches"; + public string Customer { get; init; } = ""; + public string DateCreated { get; init; } = ""; + public string DateLastModified { get; init; } = ""; + public string Notes { get; init; } = ""; + public PlateDefaultsDto PlateDefaults { get; init; } = new(); + public List Drawings { get; init; } = new(); + public List Plates { get; init; } = new(); + } + + public record PlateDefaultsDto + { + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + } + + public record DrawingDto + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Customer { get; init; } = ""; + public ColorDto Color { get; init; } = new(); + public QuantityDto Quantity { get; init; } = new(); + public int Priority { get; init; } + public ConstraintsDto Constraints { get; init; } = new(); + public MaterialDto Material { get; init; } = new(); + public SourceDto Source { get; init; } = new(); + } + + public record PlateDto + { + public int Id { get; init; } + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public int Quantity { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + public List Parts { get; init; } = new(); + } + + public record PartDto + { + public int DrawingId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Rotation { get; init; } + } + + public record SizeDto + { + public double Width { get; init; } + public double Height { get; init; } + } + + public record MaterialDto + { + public string Name { get; init; } = ""; + public string Grade { get; init; } = ""; + public double Density { get; init; } + } + + public record SpacingDto + { + public double Left { get; init; } + public double Top { get; init; } + public double Right { get; init; } + public double Bottom { get; init; } + } + + public record ColorDto + { + public int A { get; init; } = 255; + public int R { get; init; } + public int G { get; init; } + public int B { get; init; } + } + + public record QuantityDto + { + public int Required { get; init; } + } + + public record ConstraintsDto + { + public double StepAngle { get; init; } + public double StartAngle { get; init; } + public double EndAngle { get; init; } + public bool Allow180Equivalent { get; init; } + } + + public record SourceDto + { + public string Path { get; init; } = ""; + public OffsetDto Offset { get; init; } = new(); + } + + public record OffsetDto + { + public double X { get; init; } + public double Y { get; init; } + } + } +} +``` + +- [ ] **Step 2: Build to verify DTOs compile** + +Run: `dotnet build OpenNest.IO/OpenNest.IO.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestFormat.cs +git commit -m "feat: add NestFormat DTOs for JSON nest file format v2" +``` + +--- + +## Chunk 2: Rewrite NestWriter + +### Task 2: Rewrite NestWriter to use JSON serialization + +**Files:** +- Rewrite: `OpenNest.IO/NestWriter.cs` + +The writer keeps the same public API: `NestWriter(Nest nest)` constructor and `bool Write(string file)`. Internally it builds a `NestDto` from the domain model, serializes it to `nest.json`, and writes each drawing's program to `programs/program-N`. + +The G-code writing methods (`WriteDrawing`, `GetCodeString`, `GetLayerString`) are preserved exactly — they write program G-code to streams, which is unchanged. The `WritePlate` method and all XML methods (`AddNestInfo`, `AddPlateInfo`, `AddDrawingInfo`) are removed. + +- [ ] **Step 1: Rewrite `NestWriter.cs`** + +Replace the entire file. Key changes: +- Remove `using System.Xml` +- Add `using System.Text.Json` +- Remove `AddNestInfo()`, `AddPlateInfo()`, `AddDrawingInfo()`, `AddPlates()`, `WritePlate()` methods +- Add `BuildNestDto()` method that maps domain model → DTOs +- `Write()` now serializes `NestDto` to `nest.json` and writes programs to `programs/program-N` +- Keep `WriteDrawing()`, `GetCodeString()`, `GetLayerString()` exactly as-is + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using OpenNest.CNC; +using OpenNest.Math; +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 drawingDict; + + public NestWriter(Nest nest) + { + this.drawingDict = new Dictionary(); + this.nest = nest; + } + + public bool Write(string file) + { + nest.DateLastModified = DateTime.Now; + SetDrawingIds(); + + using var fileStream = new FileStream(file, FileMode.Create); + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create); + + WriteNestJson(zipArchive); + WritePrograms(zipArchive); + + return true; + } + + private void SetDrawingIds() + { + 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 ?? "", + PlateDefaults = BuildPlateDefaultsDto(), + Drawings = BuildDrawingDtos(), + Plates = BuildPlateDtos() + }; + } + + private PlateDefaultsDto BuildPlateDefaultsDto() + { + var pd = nest.PlateDefaults; + return new PlateDefaultsDto + { + Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height }, + Thickness = pd.Thickness, + Quadrant = pd.Quadrant, + PartSpacing = pd.PartSpacing, + Material = new MaterialDto + { + Name = pd.Material.Name ?? "", + Grade = pd.Material.Grade ?? "", + Density = pd.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = pd.EdgeSpacing.Left, + Top = pd.EdgeSpacing.Top, + Right = pd.EdgeSpacing.Right, + Bottom = pd.EdgeSpacing.Bottom + } + }; + } + + private List BuildDrawingDtos() + { + var list = new List(); + 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 } + } + }); + } + return list; + } + + private List BuildPlateDtos() + { + var list = new List(); + for (var i = 0; i < nest.Plates.Count; i++) + { + var plate = nest.Plates[i]; + var parts = new List(); + foreach (var part in plate.Parts) + { + var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); + parts.Add(new PartDto + { + DrawingId = match.Key, + X = part.Location.X, + Y = part.Location.Y, + Rotation = part.Rotation + }); + } + + list.Add(new PlateDto + { + Id = i + 1, + Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height }, + Thickness = plate.Thickness, + Quadrant = plate.Quadrant, + Quantity = plate.Quantity, + PartSpacing = plate.PartSpacing, + Material = new MaterialDto + { + Name = plate.Material.Name ?? "", + Grade = plate.Material.Grade ?? "", + Density = plate.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = plate.EdgeSpacing.Left, + Top = plate.EdgeSpacing.Top, + Right = plate.EdgeSpacing.Right, + Bottom = plate.EdgeSpacing.Bottom + }, + Parts = parts + }); + } + return list; + } + + 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); + } + } + + private void WriteDrawing(Stream stream, Drawing drawing) + { + var program = drawing.Program; + var writer = new StreamWriter(stream); + writer.AutoFlush = true; + + writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); + + for (var i = 0; i < drawing.Program.Length; ++i) + { + var code = drawing.Program[i]; + writer.WriteLine(GetCodeString(code)); + } + + stream.Position = 0; + } + + private string GetCodeString(ICode code) + { + switch (code.Type) + { + case CodeType.ArcMove: + { + var sb = new StringBuilder(); + var arcMove = (ArcMove)code; + + var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat); + var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat); + var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat); + var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat); + + if (arcMove.Rotation == RotationType.CW) + sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j)); + else + sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j)); + + if (arcMove.Layer != LayerType.Cut) + sb.Append(GetLayerString(arcMove.Layer)); + + return sb.ToString(); + } + + case CodeType.Comment: + { + var comment = (Comment)code; + return ":" + comment.Value; + } + + case CodeType.LinearMove: + { + var sb = new StringBuilder(); + var linearMove = (LinearMove)code; + + sb.Append(string.Format("G01X{0}Y{1}", + System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat), + System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat))); + + if (linearMove.Layer != LayerType.Cut) + sb.Append(GetLayerString(linearMove.Layer)); + + return sb.ToString(); + } + + case CodeType.RapidMove: + { + var rapidMove = (RapidMove)code; + + return string.Format("G00X{0}Y{1}", + System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat), + System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)); + } + + case CodeType.SetFeedrate: + { + var setFeedrate = (Feedrate)code; + 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; + break; + } + } + + return string.Empty; + } + + private 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; + } + } + } +} +``` + +- [ ] **Step 2: Build to verify NestWriter compiles** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestWriter.cs +git commit -m "feat: rewrite NestWriter to use JSON format v2" +``` + +--- + +## Chunk 3: Rewrite NestReader + +### Task 3: Rewrite NestReader to use JSON deserialization + +**Files:** +- Rewrite: `OpenNest.IO/NestReader.cs` + +The reader keeps the same public API: `NestReader(string file)`, `NestReader(Stream stream)`, and `Nest Read()`. Internally it reads `nest.json`, deserializes to `NestDto`, reads programs from `programs/program-N`, and assembles the domain model. + +All XML parsing, plate G-code parsing, dictionary-linking (`LinkProgramsToDrawings`, `LinkPartsToPlates`), and the helper enums/methods are removed. + +- [ ] **Step 1: Rewrite `NestReader.cs`** + +Replace the entire file: + +```csharp +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.CNC; +using OpenNest.Geometry; +using static OpenNest.IO.NestFormat; + +namespace OpenNest.IO +{ + public sealed class NestReader + { + private readonly Stream stream; + private readonly ZipArchive zipArchive; + + 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(nestJson, JsonOptions); + + var programs = ReadPrograms(dto.Drawings.Count); + var drawingMap = BuildDrawings(dto, programs); + var nest = BuildNest(dto, drawingMap); + + 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 ReadPrograms(int count) + { + var programs = new Dictionary(); + for (var i = 1; i <= count; i++) + { + var entry = zipArchive.GetEntry($"programs/program-{i}"); + if (entry == 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(); + } + return programs; + } + + private Dictionary BuildDrawings(NestDto dto, Dictionary programs) + { + var map = new Dictionary(); + 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 (programs.TryGetValue(d.Id, out var pgm)) + drawing.Program = pgm; + + map[d.Id] = drawing; + } + return map; + } + + private Nest BuildNest(NestDto dto, Dictionary 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; + + // Plate defaults + var pd = dto.PlateDefaults; + nest.PlateDefaults.Size = new Size(pd.Size.Width, pd.Size.Height); + nest.PlateDefaults.Thickness = pd.Thickness; + nest.PlateDefaults.Quadrant = pd.Quadrant; + nest.PlateDefaults.PartSpacing = pd.PartSpacing; + nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density); + nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top); + + // 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 Size(p.Size.Width, p.Size.Height); + plate.Thickness = p.Thickness; + plate.Quadrant = p.Quadrant; + plate.Quantity = p.Quantity; + plate.PartSpacing = p.PartSpacing; + plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density); + plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top); + + foreach (var partDto in p.Parts) + { + 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)); + plate.Parts.Add(part); + } + + nest.Plates.Add(plate); + } + + return nest; + } + } +} +``` + +- [ ] **Step 2: Build to verify NestReader compiles** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestReader.cs +git commit -m "feat: rewrite NestReader to use JSON format v2" +``` + +--- + +## Chunk 4: Smoke Test + +### Task 4: Manual smoke test via OpenNest.Console + +**Files:** None modified — this is a verification step. + +Use the `OpenNest.Console` project (or the MCP server) to verify round-trip: create a nest, save it, reload it, confirm data is intact. + +- [ ] **Step 1: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors. + +- [ ] **Step 2: Round-trip test via MCP tools** + +Use the OpenNest MCP tools to: +1. Create a drawing (e.g. a rectangle via `create_drawing`) +2. Create a plate via `create_plate` +3. Fill the plate via `fill_plate` +4. Save the nest via the console app or verify `get_plate_info` shows parts +5. If a nest file exists on disk, load it with `load_nest` and verify `get_plate_info` returns the same data + +- [ ] **Step 3: Inspect the ZIP contents** + +Unzip a saved nest file and verify: +- `nest.json` exists with correct structure +- `programs/program-1` (etc.) exist with G-code content +- No `info`, `drawing-info`, `plate-info`, or `plate-NNN` files exist + +- [ ] **Step 4: Commit any fixes** + +If any issues were found and fixed, commit them: + +```bash +git add -u +git commit -m "fix: address issues found during nest format v2 smoke test" +``` diff --git a/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md b/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md new file mode 100644 index 0000000..639925d --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md @@ -0,0 +1,134 @@ +# Nest File Format v2 Design + +## Problem + +The current nest file format stores metadata across three separate XML files (`info`, `drawing-info`, `plate-info`) plus per-plate G-code files for part placements inside a ZIP archive. This results in ~400 lines of hand-written XML read/write code, fragile dictionary-linking to reconnect drawings/plates by ID after parsing, and the overhead of running the full G-code parser just to extract part positions. + +## Design + +### File Structure + +The nest file remains a ZIP archive. Contents: + +``` +nest.json +programs/ + program-1 + program-2 + ... +``` + +- **`nest.json`** — single JSON file containing all metadata and part placements. +- **`programs/program-N`** — G-code text for each drawing's CNC program (1-indexed, no zero-padding). Previously stored at the archive root as `program-NNN` (zero-padded). Parsed by `ProgramReader`, written by existing G-code serialization logic. Format unchanged. + +Plate G-code files (`plate-NNN`) are removed. Part placements are stored inline in `nest.json`. + +### JSON Schema + +```json +{ + "version": 2, + "name": "string", + "units": "Inches | Millimeters", + "customer": "string", + "dateCreated": "2026-03-12T10:30:00", + "dateLastModified": "2026-03-12T14:00:00", + "notes": "string (plain JSON, no URI-escaping)", + "plateDefaults": { + "size": { "width": 0.0, "height": 0.0 }, + "thickness": 0.0, + "quadrant": 1, + "partSpacing": 0.0, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 } + }, + "drawings": [ + { + "id": 1, + "name": "string", + "customer": "string", + "color": { "a": 255, "r": 0, "g": 0, "b": 0 }, + "quantity": { "required": 0 }, + "priority": 0, + "constraints": { + "stepAngle": 0.0, + "startAngle": 0.0, + "endAngle": 0.0, + "allow180Equivalent": false + }, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "source": { + "path": "string", + "offset": { "x": 0.0, "y": 0.0 } + } + } + ], + "plates": [ + { + "id": 1, + "size": { "width": 0.0, "height": 0.0 }, + "thickness": 0.0, + "quadrant": 1, + "quantity": 1, + "partSpacing": 0.0, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 }, + "parts": [ + { "drawingId": 1, "x": 0.0, "y": 0.0, "rotation": 0.0 } + ] + } + ] +} +``` + +Key details: +- **Version**: `"version": 2` at the top level for future format migration. +- Drawing `id` values are 1-indexed, matching `programs/program-N` filenames. +- Part `rotation` is stored in **radians** (matches internal domain model, no conversion needed). +- Part `drawingId` references the drawing's `id` in the `drawings` array. +- **Dates**: local time, serialized via `DateTime.ToString("o")` (ISO 8601 round-trip format with timezone offset). +- **Notes**: stored as plain JSON strings. The v1 URI-escaping (`Uri.EscapeDataString`) is not needed since JSON handles special characters natively. +- `quantity.required` is the only quantity persisted; `nested` is computed at load time from part placements. +- **Units**: enum values match the domain model: `Inches` or `Millimeters`. +- **Size**: uses `width`/`height` matching the `OpenNest.Geometry.Size` struct. +- **Drawing.Priority** and **Drawing.Constraints** (stepAngle, startAngle, endAngle, allow180Equivalent) are now persisted (v1 omitted these). +- **Empty collections**: `drawings` and `plates` arrays are always present (may be empty `[]`). The `programs/` folder is empty when there are no drawings. + +### Serialization Approach + +Use `System.Text.Json` with small DTO (Data Transfer Object) classes for serialization. The DTOs map between the domain model and the JSON structure, keeping serialization concerns out of the domain classes. + +### What Changes + +| File | Change | +|------|--------| +| `NestWriter.cs` | Replace all XML writing and plate G-code writing with JSON serialization. Programs written to `programs/` folder. | +| `NestReader.cs` | Replace all XML parsing, plate G-code parsing, and dictionary-linking with JSON deserialization. Programs read from `programs/` folder. | + +### What Stays the Same + +| File | Reason | +|------|--------| +| `ProgramReader.cs` | G-code parsing for CNC programs is unchanged. | +| `NestWriter` G-code writing (`WriteDrawing`, `GetCodeString`) | G-code serialization for programs is unchanged. | +| `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs` | Unrelated to nest file format. | +| Domain model classes | No changes needed. | + +### Public API + +The public API is unchanged: +- `NestReader(string file)` and `NestReader(Stream stream)` constructors preserved. +- `NestReader.Read()` returns `Nest`. +- `NestWriter(Nest nest)` constructor preserved. +- `NestWriter.Write(string file)` returns `bool`. + +### Callers (no changes needed) + +- `MainForm.cs:329` — `new NestReader(path)` +- `MainForm.cs:363` — `new NestReader(dlg.FileName)` +- `EditNestForm.cs:212` — `new NestWriter(Nest)` +- `EditNestForm.cs:223` — `new NestWriter(nst)` +- `Document.cs:27` — `new NestWriter(Nest)` +- `OpenNest.Console/Program.cs:94` — `new NestReader(nestFile)` +- `OpenNest.Console/Program.cs:190` — `new NestWriter(nest)` +- `OpenNest.Mcp/InputTools.cs:30` — `new NestReader(path)`