From 39f28c7fa98a0d92a653ccdfaf52d1ad90ee09df Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 22 Oct 2026 21:51:27 -0400 Subject: [PATCH] feat(server): report a nest's sheet sizes and part counts GET /api/nests/{id}/sheets reads the stored archive's nest.json and returns the sheet sizes it needs (grouped, width the shorter side, quantities summed) and each drawing's required and nested counts, skipping the trailing empty plate as plateCount does. MaterialLedger builds material holds from it. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/NestSheets.cs | 40 ++++++ OpenNest.Server.Tests/NestSheetsTests.cs | 149 +++++++++++++++++++++++ OpenNest.Server/NestArchive.cs | 110 ++++++++++++++--- OpenNest.Server/Program.cs | 10 ++ docs/nest-storage.md | 11 ++ 5 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 OpenNest.Data/NestSheets.cs create mode 100644 OpenNest.Server.Tests/NestSheetsTests.cs diff --git a/OpenNest.Data/NestSheets.cs b/OpenNest.Data/NestSheets.cs new file mode 100644 index 00000000..0987df86 --- /dev/null +++ b/OpenNest.Data/NestSheets.cs @@ -0,0 +1,40 @@ +namespace OpenNest.Data; + +/// +/// The sheets a saved nest needs and the parts it places, returned by +/// GET /api/nests/{id}/sheets. Read from the stored archive at . +/// +public sealed class NestSheets +{ + public Guid NestId { get; init; } + + public int Revision { get; init; } + + /// One entry per distinct sheet size, ordered by width, then length. + public IReadOnlyList Sheets { get; init; } = Array.Empty(); + + /// One entry per drawing, including drawings with nothing nested, ordered by name. + public IReadOnlyList Parts { get; init; } = Array.Empty(); +} + +/// A sheet size as stored, with the shorter side. +public sealed class NestSheetSize +{ + public double Width { get; init; } + + public double Length { get; init; } + + /// Sheets of this size to cut: the sum of each such plate's quantity. + public int Quantity { get; init; } +} + +/// How many of one drawing the nest requires and how many it places. +public sealed class NestPartCount +{ + public string Name { get; init; } = ""; + + public int Required { get; init; } + + /// Parts placed across all plates, each plate counted by its quantity. + public int Nested { get; init; } +} diff --git a/OpenNest.Server.Tests/NestSheetsTests.cs b/OpenNest.Server.Tests/NestSheetsTests.cs new file mode 100644 index 00000000..1d29f3be --- /dev/null +++ b/OpenNest.Server.Tests/NestSheetsTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; + +namespace OpenNest.Server.Tests; + +/// +/// GET /api/nests/{id}/sheets reads sheet sizes and part counts from the stored archive, +/// skipping the same trailing new-plate slot the record's plate count skips. +/// +public sealed class NestSheetsTests +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; + + [Fact] + public async Task SinglePlate_ReturnsItsSizeAndParts() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = await Create(client, NestApiTests.SyntheticNest()); + + using var response = await client.GetAsync($"/api/nests/{id}/sheets"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var sheets = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((id, 1), (sheets.NestId, sheets.Revision)); + Assert.Equal([(20.0, 30.0, 1)], Sizes(sheets)); + Assert.Equal([("Synthetic rectangle", 1, 1)], Parts(sheets)); + } + + [Fact] + public async Task PlateQuantity_MultipliesSheetsAndNestedParts() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + var plate = nest.Plates.Single(); + plate.Quantity = 3; + plate.Parts.Add(new Part(nest.Drawings.Single(), new Vector(10, 3))); + + var sheets = await Sheets(client, nest); + + Assert.Equal([(20.0, 30.0, 3)], Sizes(sheets)); + Assert.Equal([("Synthetic rectangle", 1, 6)], Parts(sheets)); + } + + [Fact] + public async Task TrailingEmptyPlate_IsNotASheet() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + nest.Plates.Add(new Plate(new Size(48, 96)) { Quantity = 1 }); + + var sheets = await Sheets(client, nest); + + Assert.Equal([(20.0, 30.0, 1)], Sizes(sheets)); + } + + [Fact] + public async Task SameSizePlates_GroupEvenWhenStoredRotated() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + var drawing = nest.Drawings.Single(); + var rotated = new Plate(new Size(30, 20)) { Quantity = 2 }; + rotated.Parts.Add(new Part(drawing, new Vector(2, 3))); + nest.Plates.Add(rotated); + var larger = new Plate(new Size(60, 10.125)) { Quantity = 1 }; + larger.Parts.Add(new Part(drawing, new Vector(2, 3))); + nest.Plates.Add(larger); + + var sheets = await Sheets(client, nest); + + Assert.Equal([(10.125, 60.0, 1), (20.0, 30.0, 3)], Sizes(sheets)); + Assert.Equal([("Synthetic rectangle", 1, 4)], Parts(sheets)); + } + + [Fact] + public async Task DrawingWithNothingNested_IsListedWithZero() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + var unplaced = new Drawing("Another rectangle", nest.Drawings.Single().Program); + unplaced.Quantity.Required = 5; + nest.Drawings.Add(unplaced); + + var sheets = await Sheets(client, nest); + + Assert.Equal([("Another rectangle", 5, 0), ("Synthetic rectangle", 1, 1)], Parts(sheets)); + } + + // NestWriter never writes a part without its drawing; NestReader drops one if it finds it. + [Fact] + public async Task PartWithMissingDrawing_IsSkipped() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var archive = TestNests.Zip("nest.json", """ + {"drawings":[{"id":1,"name":"Kept","quantity":{"required":2}}], + "plates":[{"id":1,"size":{"width":48,"length":96},"quantity":2, + "parts":[{"drawingId":1},{"drawingId":7}]}]} + """); + var id = await Create(client, archive); + + var sheets = (await client.GetFromJsonAsync($"/api/nests/{id}/sheets", Options))!; + + Assert.Equal([(48.0, 96.0, 2)], Sizes(sheets)); + Assert.Equal([("Kept", 2, 2)], Parts(sheets)); + } + + [Fact] + public async Task UnknownNest_Returns404() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/sheets"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private static (double, double, int)[] Sizes(NestSheets sheets) => + sheets.Sheets.Select(s => (s.Width, s.Length, s.Quantity)).ToArray(); + + private static (string, int, int)[] Parts(NestSheets sheets) => + sheets.Parts.Select(p => (p.Name, p.Required, p.Nested)).ToArray(); + + private static async Task Sheets(HttpClient client, Nest nest) + { + var id = await Create(client, nest); + return (await client.GetFromJsonAsync($"/api/nests/{id}/sheets", Options))!; + } + + private static Task Create(HttpClient client, Nest nest) => Create(client, NestApiTests.Archive(nest)); + + private static async Task Create(HttpClient client, byte[] archive) + { + using var created = await client.PostAsync("/api/nests", + NestRevisionTests.Body(new NestRecord { Name = "Sheets" }, archive)); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + return (await created.Content.ReadFromJsonAsync(Options))!.Id; + } +} diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs index 71eabee3..a6639d8b 100644 --- a/OpenNest.Server/NestArchive.cs +++ b/OpenNest.Server/NestArchive.cs @@ -24,7 +24,75 @@ internal static class NestArchive /// public static string? Apply(NestRecord record, byte[] file) { - NestJson? nest; + if (Read(file, out var nest) is { } error) + return error; + + var defaults = nest.PlateDefaults ?? new PlateDefaultsJson(); + // NestReader: an absent nest-level material is an empty one; only an explicit null + // falls back to the plate defaults. Thickness falls back when not positive. + record.Material = (nest.Material ?? defaults.Material)?.Name ?? ""; + record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness; + + // NestReader drops parts whose drawing is missing. Cut-off parts are never written as + // parts, so every remaining part counts. + var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet(); + var plates = NestPlates(nest, drawingIds); + record.PartCount = plates.Sum(p => (p.Parts ?? new()).Count(part => drawingIds.Contains(part.DrawingId))); + record.PlateCount = plates.Count; + return null; + } + + /// + /// Reads the sheet sizes and part counts the archive's plates need, by the same plate + /// rules as . Returns an error when the bytes are not a readable nest archive. + /// + public static string? ReadSheets(NestRecord record, byte[] file, out NestSheets? sheets) + { + sheets = null; + if (Read(file, out var nest) is { } error) + return error; + + var drawings = nest.Drawings ?? new(); + var drawingIds = drawings.Select(d => d.Id).ToHashSet(); + var plates = NestPlates(nest, drawingIds); + + // Width is the shorter side, so a plate stored rotated groups with its unrotated twin. + var sizes = plates + .Select(p => (Size: p.Size ?? new SizeJson(), p.Quantity)) + .GroupBy(p => (Width: System.Math.Min(p.Size.Width, p.Size.Length), Length: System.Math.Max(p.Size.Width, p.Size.Length))) + .OrderBy(g => g.Key.Width) + .ThenBy(g => g.Key.Length) + .Select(g => new NestSheetSize { Width = g.Key.Width, Length = g.Key.Length, Quantity = g.Sum(p => p.Quantity) }) + .ToList(); + + var nested = new Dictionary(); + foreach (var plate in plates) + { + foreach (var part in plate.Parts ?? new()) + { + if (drawingIds.Contains(part.DrawingId)) + nested[part.DrawingId] = nested.GetValueOrDefault(part.DrawingId) + plate.Quantity; + } + } + + var parts = drawings + .Select(d => new NestPartCount + { + Name = d.Name ?? "", + Required = d.Quantity?.Required ?? 0, + Nested = nested.GetValueOrDefault(d.Id), + }) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToList(); + + sheets = new NestSheets { NestId = record.Id, Revision = record.Revision, Sheets = sizes, Parts = parts }; + return null; + } + + private static string? Read(byte[] file, out NestJson nest) + { + nest = null!; + NestJson? read; try { using var archive = new ZipArchive(new MemoryStream(file, writable: false), ZipArchiveMode.Read); @@ -37,35 +105,32 @@ internal static class NestArchive using (var stream = entry.Open()) CopyBounded(stream, json); json.Position = 0; - nest = JsonSerializer.Deserialize(json, Options); + read = JsonSerializer.Deserialize(json, Options); } catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException) { return "The file is not a readable nest archive."; } - if (nest is null) + if (read is null) return "The nest archive's nest.json is empty."; + nest = read; + return null; + } - var defaults = nest.PlateDefaults ?? new PlateDefaultsJson(); - // NestReader: an absent nest-level material is an empty one; only an explicit null - // falls back to the plate defaults. Thickness falls back when not positive. - record.Material = (nest.Material ?? defaults.Material)?.Name ?? ""; - record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness; - - // NestReader drops parts whose drawing is missing. Cut-off parts are never written as - // parts, so every remaining part counts. - var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet(); + /// The nest's plates in id order, as NestReader orders them, without the new-plate slot. + private static List NestPlates(NestJson nest, HashSet drawingIds) + { var plates = (nest.Plates ?? new()).OrderBy(p => p.Id).ToList(); - record.PartCount = plates.Sum(p => (p.Parts ?? new()).Count(part => drawingIds.Contains(part.DrawingId))); // PlateDisplayNumbering.DisplayedPlateCount: a trailing plate with nothing on it is the // editor's new-plate slot, not a plate of the nest. var last = plates.LastOrDefault(); var trailingEmpty = last is not null && !(last.Parts ?? new()).Any(part => drawingIds.Contains(part.DrawingId)) && (last.CutOffs ?? new()).Count == 0; - record.PlateCount = plates.Count - (trailingEmpty ? 1 : 0); - return null; + if (trailingEmpty) + plates.RemoveAt(plates.Count - 1); + return plates; } // The declared entry size is untrusted; stop at the cap however much the data inflates. @@ -106,15 +171,30 @@ internal static class NestArchive private sealed class DrawingJson { public int Id { get; set; } + public string? Name { get; set; } = ""; + public QuantityJson? Quantity { get; set; } = new(); + } + + private sealed class QuantityJson + { + public int Required { get; set; } } private sealed class PlateJson { public int Id { get; set; } + public SizeJson? Size { get; set; } = new(); + public int Quantity { get; set; } = 1; public List? Parts { get; set; } public List? CutOffs { get; set; } } + private sealed class SizeJson + { + public double Width { get; set; } + public double Length { get; set; } + } + private sealed class PartJson { public int DrawingId { get; set; } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 2a0c5784..4c211ff3 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -49,6 +49,16 @@ app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) => app.MapGet("/api/nests/{id:guid}/file", (Guid id, NestDatabase db) => db.GetFile(id) is { } file ? Results.File(file, "application/zip", $"{id}.nest") : Results.NotFound()); +// Sheet sizes and part counts the stored archive needs (inventory holds read this). +app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) => +{ + if (db.Get(id) is not { } record || db.GetFile(id) is not { } file) + return Results.NotFound(); + return NestArchive.ReadSheets(record, file, out var sheets) is { } archiveError + ? Results.Problem(archiveError) + : Results.Ok(sheets); +}); + // Next generated nest name for a year; one counter per year shared by every client. app.MapPost("/api/nest-names/next", (int year, NestDatabase db) => { diff --git a/docs/nest-storage.md b/docs/nest-storage.md index ef5407c2..05a167e4 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -102,6 +102,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr | GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter | | GET | `/api/nests/{id}` | — | `NestRecord` or 404 | | GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 | +| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable | | POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing | | PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown | | PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 | @@ -121,6 +122,16 @@ of hidden records return 404. Changed file/metadata saves and visibility changes append snapshots atomically; an identical save at the current revision is a no-op. Restore appends a new snapshot, never rewrites its source or intervening history. +### Sheets and part counts + +`/sheets` lists what a nest needs cut, for inventory holds (MaterialLedger). It +skips the trailing empty new-plate slot, as `plateCount` does. Sheets group by +size with `width` the shorter side, sizes as stored (unrounded), `quantity` the +sum of those plates' quantities, ordered by width then length. `parts` has one +entry per drawing, ordered by name: `required` is the drawing's required +quantity, `nested` its parts on each plate times that plate's quantity; parts +whose drawing is missing are skipped. + ### Generated nest names **File > New** names a nest `N-` (`OpenNest.Data.NestNames`): the