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); + } +}