mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 12:14:14 -04:00
Merge branch 'master' of https://git.thecozycat.net/aj/OpenNest
This commit is contained in:
commit
f145826309
4 files changed
+528
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user