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