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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-23 06:01:43 -04:00
1 parent 07e7d7edcd
commit 39f28c7fa9
5 files changed
+305 -15

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
namespace OpenNest.Data;
/// <summary>
/// The sheets a saved nest needs and the parts it places, returned by
/// <c>GET /api/nests/{id}/sheets</c>. Read from the stored archive at <see cref="Revision"/>.
/// </summary>
public sealed class NestSheets
{
public Guid NestId { get; init; }
public int Revision { get; init; }
/// <summary>One entry per distinct sheet size, ordered by width, then length.</summary>
public IReadOnlyList<NestSheetSize> Sheets { get; init; } = Array.Empty<NestSheetSize>();
/// <summary>One entry per drawing, including drawings with nothing nested, ordered by name.</summary>
public IReadOnlyList<NestPartCount> Parts { get; init; } = Array.Empty<NestPartCount>();
}
/// <summary>A sheet size as stored, with <see cref="Width"/> the shorter side.</summary>
public sealed class NestSheetSize
{
public double Width { get; init; }
public double Length { get; init; }
/// <summary>Sheets of this size to cut: the sum of each such plate's quantity.</summary>
public int Quantity { get; init; }
}
/// <summary>How many of one drawing the nest requires and how many it places.</summary>
public sealed class NestPartCount
{
public string Name { get; init; } = "";
public int Required { get; init; }
/// <summary>Parts placed across all plates, each plate counted by its quantity.</summary>
public int Nested { get; init; }
}
+149
View File
@@ -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;
/// <summary>
/// <c>GET /api/nests/{id}/sheets</c> reads sheet sizes and part counts from the stored archive,
/// skipping the same trailing new-plate slot the record's plate count skips.
/// </summary>
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<NestSheets>(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<NestSheets>($"/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<NestSheets> Sheets(HttpClient client, Nest nest)
{
var id = await Create(client, nest);
return (await client.GetFromJsonAsync<NestSheets>($"/api/nests/{id}/sheets", Options))!;
}
private static Task<Guid> Create(HttpClient client, Nest nest) => Create(client, NestApiTests.Archive(nest));
private static async Task<Guid> 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<NestRecord>(Options))!.Id;
}
}
+95 -15
View File
@@ -24,7 +24,75 @@ internal static class NestArchive
/// </summary>
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;
}
/// <summary>
/// Reads the sheet sizes and part counts the archive's plates need, by the same plate
/// rules as <see cref="Apply"/>. Returns an error when the bytes are not a readable nest archive.
/// </summary>
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<int, int>();
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<NestJson>(json, Options);
read = JsonSerializer.Deserialize<NestJson>(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();
/// <summary>The nest's plates in id order, as NestReader orders them, without the new-plate slot.</summary>
private static List<PlateJson> NestPlates(NestJson nest, HashSet<int> 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<PartJson>? Parts { get; set; }
public List<CutOffJson>? 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; }
+10
View File
@@ -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) =>
{
+11
View File
@@ -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<yy>-<seq>` (`OpenNest.Data.NestNames`): the