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