diff --git a/OpenNest.Data/NestDetails.cs b/OpenNest.Data/NestDetails.cs
new file mode 100644
index 0000000..6af06a4
--- /dev/null
+++ b/OpenNest.Data/NestDetails.cs
@@ -0,0 +1,76 @@
+using System.Linq;
+
+namespace OpenNest.Data;
+
+///
+/// Plate and drawing rows for one saved nest, shown below the nest list in the
+/// Database-mode nest browser. The server stores only nest-level metadata, so these
+/// rows are built from the nest's downloaded archive.
+///
+public sealed class NestDetails
+{
+ public Units Units { get; init; }
+
+ /// One row per plate, in nest order.
+ public IReadOnlyList Plates { get; init; } = Array.Empty();
+
+ /// One row per drawing (cutoffs excluded), in nest order.
+ public IReadOnlyList Drawings { get; init; } = Array.Empty();
+
+ public static NestDetails FromNest(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+
+ var plates = new List(nest.Plates.Count);
+ var nested = new Dictionary(ReferenceEqualityComparer.Instance);
+
+ for (var i = 0; i < nest.Plates.Count; i++)
+ {
+ var plate = nest.Plates[i];
+ var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
+ foreach (var part in parts)
+ nested[part.BaseDrawing] = nested.GetValueOrDefault(part.BaseDrawing) + plate.Quantity;
+
+ plates.Add(new NestPlateDetail(
+ Number: i + 1,
+ Duplicates: plate.Quantity,
+ Width: plate.Size.Width,
+ Length: plate.Size.Length,
+ PartCount: parts.Count,
+ DrawingCount: parts.Select(part => part.BaseDrawing).Distinct(ReferenceEqualityComparer.Instance).Count(),
+ // A zero-size plate has no meaningful utilization; report 0 rather than NaN.
+ Utilization: plate.Area() > 0 ? plate.Utilization() : 0));
+ }
+
+ var drawings = nest.Drawings
+ .Where(drawing => !drawing.IsCutOff)
+ .Select(drawing => new NestDrawingDetail(
+ Name: drawing.Name ?? "",
+ Customer: drawing.Customer ?? "",
+ Required: drawing.Quantity.Required,
+ Nested: nested.GetValueOrDefault(drawing),
+ Area: drawing.Area))
+ .ToList();
+
+ return new NestDetails { Units = nest.Units, Plates = plates, Drawings = drawings };
+ }
+}
+
+/// 1-based position of the plate in the nest.
+/// How many times the plate is cut ().
+/// Parts on one copy of the plate, cutoffs excluded.
+/// Fraction of the plate area used by parts, 0 to 1.
+public sealed record NestPlateDetail(
+ int Number,
+ int Duplicates,
+ double Width,
+ double Length,
+ int PartCount,
+ int DrawingCount,
+ double Utilization);
+
+/// Parts placed across all plates, counting each plate's duplicates.
+public sealed record NestDrawingDetail(string Name, string Customer, int Required, int Nested, double Area)
+{
+ public int Remaining => System.Math.Max(0, Required - Nested);
+}
diff --git a/OpenNest.Data/NestDetailsSession.cs b/OpenNest.Data/NestDetailsSession.cs
new file mode 100644
index 0000000..27aed5f
--- /dev/null
+++ b/OpenNest.Data/NestDetailsSession.cs
@@ -0,0 +1,108 @@
+namespace OpenNest.Data;
+
+///
+/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
+/// the highlight starts a new load and cancels the previous one; a superseded load's
+/// result or failure is discarded, so details never belong to a nest that is no longer
+/// highlighted. Call from one thread (the UI thread).
+///
+public sealed class NestDetailsSession : IDisposable
+{
+ private readonly Func> _load;
+ private CancellationTokenSource? _pending;
+ private int _generation;
+ private bool _disposed;
+
+ ///
+ /// Builds the details of one nest; throws when the nest cannot be read (for example
+ /// when it no longer exists on the server).
+ ///
+ public NestDetailsSession(Func> load)
+ {
+ ArgumentNullException.ThrowIfNull(load);
+ _load = load;
+ }
+
+ /// The nest of the latest load; null before the first load and after .
+ public Guid? NestId { get; private set; }
+
+ /// Details of ; null while loading, after a failure and after .
+ public NestDetails? Details { get; private set; }
+
+ /// True while the latest load is outstanding; superseded loads do not count.
+ public bool IsLoading { get; private set; }
+
+ ///
+ /// Loads one nest's details. Returns true when they were applied, false when a later
+ /// load or superseded this one. A failure of the latest load
+ /// propagates; failures of superseded loads are ignored.
+ ///
+ public async Task LoadAsync(Guid id)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ // Supersede before cancelling: a load that completes synchronously when cancelled
+ // must already see itself as superseded. Each source is released by its own load.
+ var cancellation = Supersede();
+ var generation = _generation;
+ NestId = id;
+ IsLoading = true;
+
+ try
+ {
+ var details = await _load(id, cancellation.Token);
+ if (generation != _generation)
+ return false;
+
+ Details = details;
+ return true;
+ }
+ catch (Exception) when (generation != _generation)
+ {
+ return false;
+ }
+ finally
+ {
+ if (ReferenceEquals(_pending, cancellation))
+ _pending = null;
+ if (generation == _generation)
+ IsLoading = false;
+ cancellation.Dispose();
+ }
+ }
+
+ /// Forgets the current details and discards any load in flight (no nest highlighted).
+ public void Clear()
+ {
+ if (_disposed)
+ return;
+
+ _generation++;
+ var pending = _pending;
+ _pending = null;
+ NestId = null;
+ Details = null;
+ IsLoading = false;
+ pending?.Cancel();
+ }
+
+ private CancellationTokenSource Supersede()
+ {
+ var previous = _pending;
+ var cancellation = new CancellationTokenSource();
+ _pending = cancellation;
+ _generation++;
+ Details = null;
+ previous?.Cancel();
+ return cancellation;
+ }
+
+ /// Cancels any load in flight (which releases its own source when it ends).
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ Clear();
+ _disposed = true;
+ }
+}
diff --git a/OpenNest.Tests/Data/NestDetailsSessionTests.cs b/OpenNest.Tests/Data/NestDetailsSessionTests.cs
new file mode 100644
index 0000000..4c6c51c
--- /dev/null
+++ b/OpenNest.Tests/Data/NestDetailsSessionTests.cs
@@ -0,0 +1,165 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestDetailsSessionTests
+{
+ [Fact]
+ public async Task Load_AppliesTheNestsDetails()
+ {
+ var id = Guid.NewGuid();
+ var expected = new NestDetails();
+ var requested = new List();
+ using var session = new NestDetailsSession((nestId, _) =>
+ {
+ requested.Add(nestId);
+ return Task.FromResult(expected);
+ });
+
+ Assert.True(await session.LoadAsync(id));
+
+ Assert.Equal(new[] { id }, requested);
+ Assert.Equal(id, session.NestId);
+ Assert.Same(expected, session.Details);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerNestsDetails()
+ {
+ var loads = new ControlledLoads();
+ using var session = new NestDetailsSession(loads.Load);
+ var first = Guid.NewGuid();
+ var second = Guid.NewGuid();
+
+ var firstLoad = session.LoadAsync(first);
+ var secondLoad = session.LoadAsync(second);
+ Assert.True(loads.Tokens[0].IsCancellationRequested);
+
+ var secondDetails = new NestDetails();
+ loads.Results[1].SetResult(secondDetails);
+ Assert.True(await secondLoad);
+ loads.Results[0].SetResult(new NestDetails());
+ Assert.False(await firstLoad);
+
+ Assert.Equal(second, session.NestId);
+ Assert.Same(secondDetails, session.Details);
+ }
+
+ [Fact]
+ public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsTheDetailsAndPropagates()
+ {
+ var loads = new ControlledLoads();
+ using var session = new NestDetailsSession(loads.Load);
+
+ var applied = session.LoadAsync(Guid.NewGuid());
+ loads.Results[0].SetResult(new NestDetails());
+ await applied;
+
+ var superseded = session.LoadAsync(Guid.NewGuid());
+ var latest = session.LoadAsync(Guid.NewGuid());
+ Assert.Null(session.Details);
+
+ loads.Results[1].SetException(new InvalidOperationException("old"));
+ Assert.False(await superseded);
+ Assert.True(session.IsLoading);
+
+ loads.Results[2].SetException(new InvalidOperationException("This nest no longer exists on the server."));
+ var error = await Assert.ThrowsAsync(() => latest);
+ Assert.Contains("no longer exists", error.Message);
+ Assert.Null(session.Details);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task SupersededLoad_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure()
+ {
+ var calls = 0;
+ var latestDetails = new NestDetails();
+ using var session = new NestDetailsSession((_, token) =>
+ {
+ if (++calls > 1)
+ return Task.FromResult(latestDetails);
+
+ var cancelled = new TaskCompletionSource();
+ token.Register(() => cancelled.SetCanceled(token));
+ return cancelled.Task;
+ });
+
+ var superseded = session.LoadAsync(Guid.NewGuid());
+ var latest = session.LoadAsync(Guid.NewGuid());
+
+ Assert.False(await superseded);
+ Assert.True(await latest);
+ Assert.Same(latestDetails, session.Details);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task IsLoading_FollowsOnlyTheLatestLoad()
+ {
+ var loads = new ControlledLoads();
+ using var session = new NestDetailsSession(loads.Load);
+
+ var first = session.LoadAsync(Guid.NewGuid());
+ var second = session.LoadAsync(Guid.NewGuid());
+ loads.Results[1].SetResult(new NestDetails());
+ await second;
+ Assert.False(session.IsLoading);
+
+ // The superseded load is still outstanding but no longer counts.
+ Assert.False(first.IsCompleted);
+ loads.Results[0].SetResult(new NestDetails());
+ Assert.False(await first);
+ Assert.False(session.IsLoading);
+ }
+
+ [Fact]
+ public async Task Clear_DiscardsTheLoadInFlightAndForgetsTheNest()
+ {
+ var loads = new ControlledLoads();
+ using var session = new NestDetailsSession(loads.Load);
+
+ var load = session.LoadAsync(Guid.NewGuid());
+ session.Clear();
+
+ Assert.True(loads.Tokens[0].IsCancellationRequested);
+ Assert.False(session.IsLoading);
+ Assert.Null(session.NestId);
+ loads.Results[0].SetResult(new NestDetails());
+ Assert.False(await load);
+ Assert.Null(session.Details);
+ }
+
+ [Fact]
+ public async Task Dispose_CancelsTheLoadAndDiscardsItsLateResponse()
+ {
+ var loads = new ControlledLoads();
+ var session = new NestDetailsSession(loads.Load);
+
+ var load = session.LoadAsync(Guid.NewGuid());
+ session.Dispose();
+
+ Assert.True(loads.Tokens[0].IsCancellationRequested);
+ loads.Results[0].SetResult(new NestDetails());
+ Assert.False(await load);
+ Assert.Null(session.Details);
+ await Assert.ThrowsAsync(() => session.LoadAsync(Guid.NewGuid()));
+ }
+
+ /// Each load waits for its own result, which the test completes explicitly.
+ private sealed class ControlledLoads
+ {
+ public List> Results { get; } = new();
+
+ public List Tokens { get; } = new();
+
+ public Task Load(Guid id, CancellationToken token)
+ {
+ var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Results.Add(result);
+ Tokens.Add(token);
+ return result.Task;
+ }
+ }
+}
diff --git a/OpenNest.Tests/Data/NestDetailsTests.cs b/OpenNest.Tests/Data/NestDetailsTests.cs
new file mode 100644
index 0000000..1a27559
--- /dev/null
+++ b/OpenNest.Tests/Data/NestDetailsTests.cs
@@ -0,0 +1,179 @@
+using OpenNest.CNC;
+using OpenNest.Data;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Data;
+
+public class NestDetailsTests
+{
+ [Fact]
+ public void FromNest_ListsEachPlateWithItsDuplicatesSizePartsAndUtilization()
+ {
+ var nest = new Nest("Job") { Units = Units.Millimeters };
+ var bracket = Rectangle("Bracket", 10, 10);
+ var gusset = Rectangle("Gusset", 20, 5);
+ nest.Drawings.Add(bracket);
+ nest.Drawings.Add(gusset);
+
+ var first = nest.CreatePlate();
+ first.Size = new Size(48, 120);
+ first.Quantity = 2;
+ first.Parts.Add(new Part(bracket));
+ first.Parts.Add(new Part(bracket));
+ first.Parts.Add(new Part(gusset));
+ var cutoff = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
+ first.CutOffs.Add(cutoff);
+ first.Parts.Add(new Part(cutoff.Drawing));
+
+ var second = nest.CreatePlate();
+ second.Size = new Size(60, 60);
+ second.Quantity = 1;
+
+ var details = NestDetails.FromNest(nest);
+
+ Assert.Equal(Units.Millimeters, details.Units);
+ Assert.Collection(
+ details.Plates,
+ plate =>
+ {
+ Assert.Equal(1, plate.Number);
+ Assert.Equal(2, plate.Duplicates);
+ Assert.Equal(48, plate.Width);
+ Assert.Equal(120, plate.Length);
+ Assert.Equal(3, plate.PartCount);
+ Assert.Equal(2, plate.DrawingCount);
+ Assert.Equal((100 + 100 + 100) / (48.0 * 120), plate.Utilization, 12);
+ },
+ plate =>
+ {
+ Assert.Equal(2, plate.Number);
+ Assert.Equal(1, plate.Duplicates);
+ Assert.Equal(0, plate.PartCount);
+ Assert.Equal(0, plate.DrawingCount);
+ Assert.Equal(0, plate.Utilization);
+ });
+ }
+
+ [Fact]
+ public void FromNest_CountsNestedDrawingsAcrossPlateDuplicates_AndSkipsCutoffs()
+ {
+ var nest = new Nest("Job");
+ var bracket = Rectangle("Bracket", 10, 10);
+ bracket.Customer = "Acme";
+ bracket.Quantity.Required = 10;
+ var gusset = Rectangle("Gusset", 20, 5);
+ gusset.Quantity.Required = 1;
+ var unplaced = Rectangle("Spare", 4, 4);
+ unplaced.Quantity.Required = 3;
+ nest.Drawings.Add(bracket);
+ nest.Drawings.Add(gusset);
+ nest.Drawings.Add(unplaced);
+
+ var first = nest.CreatePlate();
+ first.Quantity = 3;
+ first.Parts.Add(new Part(bracket));
+ first.Parts.Add(new Part(bracket));
+ var second = nest.CreatePlate();
+ second.Quantity = 1;
+ second.Parts.Add(new Part(bracket));
+ second.Parts.Add(new Part(gusset));
+ second.Parts.Add(new Part(gusset));
+ var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
+ second.CutOffs.Add(cutoff);
+ second.Parts.Add(new Part(cutoff.Drawing));
+
+ var details = NestDetails.FromNest(nest);
+
+ Assert.Collection(
+ details.Drawings,
+ drawing =>
+ {
+ Assert.Equal("Bracket", drawing.Name);
+ Assert.Equal("Acme", drawing.Customer);
+ Assert.Equal(10, drawing.Required);
+ Assert.Equal(2 * 3 + 1, drawing.Nested);
+ Assert.Equal(3, drawing.Remaining);
+ Assert.Equal(100, drawing.Area, 9);
+ },
+ drawing =>
+ {
+ Assert.Equal("Gusset", drawing.Name);
+ Assert.Equal(2, drawing.Nested);
+ Assert.Equal(0, drawing.Remaining);
+ },
+ drawing =>
+ {
+ Assert.Equal("Spare", drawing.Name);
+ Assert.Equal(0, drawing.Nested);
+ Assert.Equal(3, drawing.Remaining);
+ });
+ }
+
+ [Fact]
+ public void FromNest_ZeroSizePlate_ReportsZeroUtilizationInsteadOfNaN()
+ {
+ var nest = new Nest("Job");
+ var bracket = Rectangle("Bracket", 10, 10);
+ nest.Drawings.Add(bracket);
+ var plate = nest.CreatePlate();
+ plate.Size = new Size(0, 0);
+ plate.Parts.Add(new Part(bracket));
+
+ var detail = Assert.Single(NestDetails.FromNest(nest).Plates);
+
+ Assert.Equal(0, detail.Utilization);
+ Assert.Equal(1, detail.PartCount);
+ }
+
+ [Fact]
+ public void FromNest_OfAReadBackArchive_KeepsDuplicatesRequiredQuantitiesAndUnits()
+ {
+ // The nest browser builds details from the downloaded .nest archive, not a live nest.
+ var nest = new Nest("Job") { Units = Units.Millimeters };
+ var bracket = Rectangle("Bracket", 10, 10);
+ bracket.Quantity.Required = 8;
+ nest.Drawings.Add(bracket);
+ var plate = nest.CreatePlate();
+ plate.Size = new Size(1500, 3000);
+ plate.Quantity = 4;
+ plate.Parts.Add(new Part(bracket));
+ plate.Parts.Add(new Part(bracket, new Vector(20, 0)));
+
+ using var stream = new MemoryStream();
+ new OpenNest.IO.NestWriter(nest).Write(stream);
+ stream.Position = 0;
+ var details = NestDetails.FromNest(new OpenNest.IO.NestReader(stream).Read());
+
+ Assert.Equal(Units.Millimeters, details.Units);
+ var plateDetail = Assert.Single(details.Plates);
+ Assert.Equal(4, plateDetail.Duplicates);
+ Assert.Equal(2, plateDetail.PartCount);
+ Assert.Equal(1500, plateDetail.Width);
+ Assert.Equal(3000, plateDetail.Length);
+ var drawing = Assert.Single(details.Drawings);
+ Assert.Equal("Bracket", drawing.Name);
+ Assert.Equal(8, drawing.Required);
+ Assert.Equal(8, drawing.Nested);
+ Assert.Equal(0, drawing.Remaining);
+ }
+
+ [Fact]
+ public void FromNest_EmptyNest_HasNoRows()
+ {
+ var details = NestDetails.FromNest(new Nest("Empty"));
+
+ Assert.Empty(details.Plates);
+ Assert.Empty(details.Drawings);
+ }
+
+ private static Drawing Rectangle(string name, double width, double height)
+ {
+ var program = new OpenNest.CNC.Program();
+ program.Codes.Add(new RapidMove(new Vector(0, 0)));
+ program.Codes.Add(new LinearMove(new Vector(width, 0)));
+ program.Codes.Add(new LinearMove(new Vector(width, height)));
+ program.Codes.Add(new LinearMove(new Vector(0, height)));
+ program.Codes.Add(new LinearMove(new Vector(0, 0)));
+ return new Drawing(name, program);
+ }
+}