This commit is contained in:
aj committed 2026-10-05 12:35:13 -04:00
commit f145826309
4 files changed
+528

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
using System.Linq;
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public sealed class NestDetails
{
public Units Units { get; init; }
/// <summary>One row per plate, in nest order.</summary>
public IReadOnlyList<NestPlateDetail> Plates { get; init; } = Array.Empty<NestPlateDetail>();
/// <summary>One row per drawing (cutoffs excluded), in nest order.</summary>
public IReadOnlyList<NestDrawingDetail> Drawings { get; init; } = Array.Empty<NestDrawingDetail>();
public static NestDetails FromNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var plates = new List<NestPlateDetail>(nest.Plates.Count);
var nested = new Dictionary<Drawing, int>(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 };
}
}
/// <param name="Number">1-based position of the plate in the nest.</param>
/// <param name="Duplicates">How many times the plate is cut (<see cref="Plate.Quantity"/>).</param>
/// <param name="PartCount">Parts on one copy of the plate, cutoffs excluded.</param>
/// <param name="Utilization">Fraction of the plate area used by parts, 0 to 1.</param>
public sealed record NestPlateDetail(
int Number,
int Duplicates,
double Width,
double Length,
int PartCount,
int DrawingCount,
double Utilization);
/// <param name="Nested">Parts placed across all plates, counting each plate's duplicates.</param>
public sealed record NestDrawingDetail(string Name, string Customer, int Required, int Nested, double Area)
{
public int Remaining => System.Math.Max(0, Required - Nested);
}
+108
View File
@@ -0,0 +1,108 @@
namespace OpenNest.Data;
/// <summary>
/// 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).
/// </summary>
public sealed class NestDetailsSession : IDisposable
{
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
/// <param name="load">
/// Builds the details of one nest; throws when the nest cannot be read (for example
/// when it no longer exists on the server).
/// </param>
public NestDetailsSession(Func<Guid, CancellationToken, Task<NestDetails>> load)
{
ArgumentNullException.ThrowIfNull(load);
_load = load;
}
/// <summary>The nest of the latest load; null before the first load and after <see cref="Clear"/>.</summary>
public Guid? NestId { get; private set; }
/// <summary>Details of <see cref="NestId"/>; null while loading, after a failure and after <see cref="Clear"/>.</summary>
public NestDetails? Details { get; private set; }
/// <summary>True while the latest load is outstanding; superseded loads do not count.</summary>
public bool IsLoading { get; private set; }
/// <summary>
/// Loads one nest's details. Returns true when they were applied, false when a later
/// load or <see cref="Clear"/> superseded this one. A failure of the latest load
/// propagates; failures of superseded loads are ignored.
/// </summary>
public async Task<bool> 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();
}
}
/// <summary>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
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;
}
/// <summary>Cancels any load in flight (which releases its own source when it ends).</summary>
public void Dispose()
{
if (_disposed)
return;
Clear();
_disposed = true;
}
}
@@ -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<Guid>();
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<InvalidOperationException>(() => 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<NestDetails>();
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<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
}
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
private sealed class ControlledLoads
{
public List<TaskCompletionSource<NestDetails>> Results { get; } = new();
public List<CancellationToken> Tokens { get; } = new();
public Task<NestDetails> Load(Guid id, CancellationToken token)
{
var result = new TaskCompletionSource<NestDetails>(TaskCreationOptions.RunContinuationsAsynchronously);
Results.Add(result);
Tokens.Add(token);
return result.Task;
}
}
}
+179
View File
@@ -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);
}
}