refactor: share duplicated paging, HTTP and helper code

Roslyn duplicate scans found the same code copied across layers:
- Data: the three browse sessions share BrowseSession<TPage, TSort>;
  the remote clients share NestServerHttp (JSON options, If-Match,
  error text).
- WinForms: five paged lists share BrowsePager for loading text,
  navigation, sort glyphs and failure messages. The Add Drawings to
  Release, Drawing Library and set browser dialogs now dispose their
  browse sessions.
- Server: NestQueryRequest reuses DrawingLibraryRequest's helpers;
  revision history queries share count and paging binds.
- Engine: RemnantPlateNester delegates to DefaultPlateNester;
  CuttingPlanService reuses JointCuttingPlanSearch.Map.
- Core: LineArc and CleanHole lead-ins share LineArcApproach with
  unchanged arithmetic order, so output is bit-identical.
- IO: Dxf.GetGeometry overloads share one reader; .nest JSON test
  helpers move to NestArchiveJson.

Also fixes the production compiler warnings (CS8632, CS8603, CS0108)
and moves a test off an obsolete forwarding API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-23 08:45:36 -04:00
1 parent a00e2c3233
commit 7ce5e73a82
37 files changed
+738 -1289

No files matched your search

@@ -1,11 +1,12 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
public class CleanHoleLeadIn : LeadIn
{
private const double ApproachAngle = 135.0;
public double LineLength { get; set; }
public double ArcRadius { get; set; }
public double Kerf { get; set; }
@@ -14,42 +15,10 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
) => Approach(contourStartPoint, contourNormalAngle).ToCodes(contourStartPoint, winding);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) =>
Approach(contourStartPoint, contourNormalAngle).PiercePoint;
public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn
@@ -58,5 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength);
}
}
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A straight line into an arc that ends on the contour start, shared by
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
/// the arc radius along the contour normal; the line meets the arc at the approach angle
/// (degrees from the normal) and starts at the pierce point.
/// </summary>
internal readonly struct LineArcApproach
{
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
{
ArcCenter = arcCenter;
ArcStart = arcStart;
PiercePoint = piercePoint;
}
public Vector ArcCenter { get; }
public Vector ArcStart { get; }
public Vector PiercePoint { get; }
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
double arcRadius, double approachAngle, double lineLength)
{
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(approachAngle);
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
return new LineArcApproach(
new Vector(arcCenterX, arcCenterY),
new Vector(arcStartX, arcStartY),
new Vector(
arcStartX + lineLength * System.Math.Cos(lineAngle),
arcStartY + lineLength * System.Math.Sin(lineAngle)));
}
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
new List<ICode>
{
new RapidMove(PiercePoint),
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
};
}
}
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -14,42 +13,10 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
) => Approach(contourStartPoint, contourNormalAngle).ToCodes(contourStartPoint, winding);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) =>
Approach(contourStartPoint, contourNormalAngle).PiercePoint;
public override LeadIn Scale(double factor) =>
new LineArcLeadIn
@@ -58,5 +25,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength);
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ namespace OpenNest
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler? ClampZonesChanged;
public event EventHandler ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
+196
View File
@@ -0,0 +1,196 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>The paging facts a <see cref="BrowseSession{TPage, TSort}"/> reads from a server page.</summary>
public interface IBrowsePage
{
int Offset { get; }
/// <summary>Number of records matching the request across all pages.</summary>
int Total { get; }
/// <summary>Number of records on this page.</summary>
int ItemCount { get; }
}
/// <summary>
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
/// last page applied. Every change issues one bounded server query. A response that a later
/// request superseded is discarded (and the earlier request is cancelled), so an older result
/// can never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public abstract class BrowseSession<TPage, TSort> : IDisposable
where TPage : class, IBrowsePage
where TSort : struct, Enum
{
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
protected BrowseSession(int pageSize, int maxPageSize, TSort sort, bool descending = true)
{
if (pageSize < 1 || pageSize > maxPageSize)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {maxPageSize}.");
PageSize = pageSize;
Sort = sort;
Descending = descending;
}
public int PageSize { get; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public TSort Sort { get; private set; }
public bool Descending { get; private set; }
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public TPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.ItemCount < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
public abstract string Summary { get; }
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(TSort field)
{
Descending = EqualityComparer<TSort>.Default.Equals(field, Sort) && !Descending;
Sort = field;
return FirstPageAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>Re-queries from the first page, after a derived filter changed.</summary>
protected Task<bool> FirstPageAsync()
{
Offset = 0;
return RunAsync();
}
/// <summary>Queries the page at <see cref="Offset"/> with the current filters.</summary>
protected abstract Task<TPage> QueryAsync(CancellationToken cancellationToken);
/// <summary>The standard summary: <paramref name="empty"/> when nothing matches, else the shown range of <paramref name="noun"/>.</summary>
protected string PageSummary(string noun, string empty)
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return empty;
if (page.ItemCount == 0)
return $"No {noun} on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.ItemCount)} of {Count(page.Total)} {noun}";
}
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// A deletion, an archive or another PC can leave the requested page past the end.
if (page.ItemCount == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the server client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+12 -169
View File
@@ -1,32 +1,23 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Browse state for Add from Drawing Library: the requested search, sort, page and
/// nest filter, and the last page applied. By default only drawings with the nest's
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
/// filter. Every change issues one bounded server query. A response that a later request
/// superseded is discarded (and the earlier request is cancelled), so an older result can
/// never replace a newer one. Call from one thread (the UI thread).
/// filter. Paging, superseding and disposal follow <see cref="BrowseSession{TPage, TSort}"/>.
/// Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingLibraryBrowseSession : IDisposable
public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPage, DrawingSortField>
{
private readonly IDrawingLibrary _library;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(nest);
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
_library = library;
PageSize = pageSize;
MaterialName = (nest.Material?.Name ?? "").Trim();
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
// A nest without a thickness has nothing to match; it filters by material only.
@@ -35,8 +26,6 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
: null;
}
public int PageSize { get; }
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
public string MaterialName { get; }
@@ -53,148 +42,21 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
public bool IsFilteredToNest =>
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public DrawingLibraryPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
{
if (Search.Length > 0)
return "No drawings match the search.";
return IsFilteredToNest
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.";
}
if (page.Items.Count == 0)
return $"No drawings on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <inheritdoc />
public override string Summary =>
PageSummary("drawings",
Search.Length > 0 ? "No drawings match the search."
: IsFilteredToNest ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.");
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
public Task<bool> SetShowAllAsync(bool showAll)
{
ShowAll = showAll;
Offset = 0;
return RunAsync();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(DrawingSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// An archive or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
protected override Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
_library.QueryAsync(
new DrawingLibraryQuery
{
@@ -208,23 +70,4 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the library client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
namespace OpenNest.Data;
/// <summary>One bounded page of library drawing metadata.</summary>
public sealed class DrawingLibraryPage
public sealed class DrawingLibraryPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingLibraryRecord> Items { get; init; } = Array.Empty<DrawingLibraryRecord>();
@@ -12,4 +12,6 @@ public sealed class DrawingLibraryPage
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+9 -163
View File
@@ -1,171 +1,36 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>
/// Browse state for Nest &gt; Drawing Sets: the requested search, archived toggle, sort and
/// page, and the last page applied. Every change issues one bounded server query. A response
/// that a later request superseded is discarded (and the earlier request is cancelled), so an
/// older result can never replace a newer one. Call from one thread (the UI thread).
/// page, and the last page applied. Paging, superseding and disposal follow
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingSetBrowseSession : IDisposable
public sealed class DrawingSetBrowseSession : BrowseSession<DrawingSetPage, DrawingSetSortField>
{
private readonly IDrawingSetStore _sets;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public DrawingSetBrowseSession(IDrawingSetStore sets, int pageSize = DrawingSetQuery.DefaultLimit)
: base(pageSize, DrawingSetQuery.MaxLimit, DrawingSetSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(sets);
if (pageSize < 1 || pageSize > DrawingSetQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingSetQuery.MaxLimit}.");
_sets = sets;
PageSize = pageSize;
}
public int PageSize { get; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
/// <summary>True when archived sets are listed with the active ones.</summary>
public bool IncludeArchived { get; private set; }
public DrawingSetSortField Sort { get; private set; } = DrawingSetSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public DrawingSetPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 1-100 of 240 sets".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.";
if (page.Items.Count == 0)
return $"No sets on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} sets";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <inheritdoc />
public override string Summary =>
PageSummary("sets", Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.");
/// <summary>Lists or hides archived sets and returns to the first page.</summary>
public Task<bool> SetIncludeArchivedAsync(bool includeArchived)
{
IncludeArchived = includeArchived;
Offset = 0;
return RunAsync();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(DrawingSetSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// An archive or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
protected override Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
_sets.QueryAsync(
new DrawingSetQuery
{
@@ -177,23 +42,4 @@ public sealed class DrawingSetBrowseSession : IDisposable
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the set client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
namespace OpenNest.Data;
/// <summary>One bounded page of drawing set records, without their members.</summary>
public sealed class DrawingSetPage
public sealed class DrawingSetPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingSetRecord> Items { get; init; } = Array.Empty<DrawingSetRecord>();
@@ -12,4 +12,6 @@ public sealed class DrawingSetPage
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+8 -163
View File
@@ -1,163 +1,27 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>
/// Browse state for the Database-mode Open dialog: the requested search, sort and page,
/// and the last page applied. Every change issues one bounded server query. A response
/// that a later request superseded is discarded (and the earlier request is cancelled),
/// so an older result can never replace a newer one. Call from one thread (the UI thread).
/// and the last page applied. Paging, superseding and disposal follow
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
/// </summary>
public sealed class NestBrowseSession : IDisposable
public sealed class NestBrowseSession : BrowseSession<NestPage, NestSortField>
{
private readonly INestRepository _repository;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
NestSortField sort = NestSortField.SavedAt, bool descending = true)
: base(pageSize, NestQuery.MaxLimit, Enum.IsDefined(sort) ? sort : NestSortField.SavedAt, descending)
{
ArgumentNullException.ThrowIfNull(repository);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}.");
_repository = repository;
PageSize = pageSize;
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
Descending = descending;
}
public int PageSize { get; }
/// <inheritdoc />
public override string Summary =>
PageSummary("nests", Search.Length == 0 ? "No nests on the server." : "No nests match the filter.");
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public NestPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return Search.Length == 0 ? "No nests on the server." : "No nests match the filter.";
if (page.Items.Count == 0)
return $"No nests on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(NestSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// A deletion or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
protected override Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
_repository.QueryAsync(
new NestQuery
{
@@ -168,23 +32,4 @@ public sealed class NestBrowseSession : IDisposable
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the repository is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
namespace OpenNest.Data;
/// <summary>One page of saved-nest metadata returned by <c>GET /api/nests/query</c>.</summary>
public sealed class NestPage
public sealed class NestPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> matching records, in the requested order.</summary>
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
@@ -12,4 +12,6 @@ public sealed class NestPage
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+45
View File
@@ -0,0 +1,45 @@
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
/// <summary>Wire conventions shared by the nest server's HTTP clients.</summary>
internal static class NestServerHttp
{
/// <summary>The server's JSON shape: camel-case names and enum names.</summary>
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
/// <summary>Guards a write on the revision the caller last read.</summary>
public static void AddIfMatch(HttpRequestMessage request, int expectedRevision) =>
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
/// <summary>Throws an <see cref="IOException"/> naming the action, status and the start of the body.</summary>
public static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
}
+12 -39
View File
@@ -18,13 +18,6 @@ namespace OpenNest.Data;
/// </summary>
public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
@@ -61,7 +54,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
throw new ServerTooOldException();
if (response.StatusCode == HttpStatusCode.Conflict)
throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken));
await EnsureSuccess(response, $"save drawing '{record.Name}' to the library", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"save drawing '{record.Name}' to the library", cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != record.Id || saved.Revision != 1 || saved.PackageSha256 != Sha256(package))
throw new IOException("The nest server returned a different drawing than was saved. Reload the library before saving again.");
@@ -78,8 +71,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
using var response = await _httpClient.GetAsync(Url("api/drawings/query?" + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException();
await EnsureSuccess(response, "browse the drawing library", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, "browse the drawing library", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing page.");
if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null))
throw new IOException($"Nest server returned an invalid drawing page for a page limited to {query.Limit}.");
@@ -96,7 +89,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
await ThrowIfServerTooOld(cancellationToken);
return null;
}
await EnsureSuccess(response, $"read library drawing {id}", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing {id}", cancellationToken);
var record = await ReadRecord(response, cancellationToken);
return record.Id == id ? record : throw new IOException("Nest server returned a different library drawing.");
}
@@ -123,8 +116,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
await ThrowIfServerTooOld(cancellationToken);
return null;
}
await EnsureSuccess(response, $"read library drawing history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
@@ -170,7 +163,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
if (error is not null)
throw new ArgumentException(error, nameof(record));
using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/metadata", expectedRevision);
request.Content = JsonContent.Create(record, options: JsonOptions);
request.Content = JsonContent.Create(record, options: NestServerHttp.JsonOptions);
return await SendGuarded(request, id, expectedRevision, $"edit library drawing {id}", cancellationToken);
}
@@ -195,7 +188,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision)
{
var request = new HttpRequestMessage(method, Url(relative));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
NestServerHttp.AddIfMatch(request, expectedRevision);
return request;
}
@@ -210,7 +203,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
}
if (response.StatusCode == HttpStatusCode.Conflict)
throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken));
await EnsureSuccess(response, action, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid library drawing revision. Reload the library before saving again.");
@@ -225,7 +218,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
await ThrowIfServerTooOld(cancellationToken);
return null;
}
await EnsureSuccess(response, action, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
@@ -288,7 +281,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
private static MultipartFormDataContent BuildMultipart(DrawingLibraryRecord record, byte[] package)
{
var content = new MultipartFormDataContent();
content.Add(new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"), "metadata");
content.Add(new StringContent(JsonSerializer.Serialize(record, NestServerHttp.JsonOptions), Encoding.UTF8, "application/json"), "metadata");
var file = new ByteArrayContent(package);
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(file, "package", "drawing.nest");
@@ -297,30 +290,10 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
private static async Task<DrawingLibraryRecord> ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(JsonOptions, cancellationToken);
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(NestServerHttp.JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty library drawing record.");
}
private static async Task EnsureSuccess(HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
public void Dispose()
{
+21 -48
View File
@@ -23,13 +23,6 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private const string DrawingReleaseFeature = "releasing library drawings on their own";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
@@ -59,8 +52,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
using var response = await _httpClient.GetAsync(Url("api/sets/query?" + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
await EnsureSuccess(response, "browse drawing sets", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, "browse drawing sets", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing set page.");
if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null))
throw new IOException($"Nest server returned an invalid drawing set page for a page limited to {query.Limit}.");
@@ -121,12 +114,12 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
ValidateId(record.Id);
ValidateSet(record);
using var response = await _httpClient.PostAsync(Url("api/sets"),
JsonContent.Create(ForSave(record), options: JsonOptions), cancellationToken);
JsonContent.Create(ForSave(record), options: NestServerHttp.JsonOptions), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
if (response.StatusCode == HttpStatusCode.Conflict)
throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken));
await EnsureSuccess(response, $"save drawing set '{record.Name}'", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"save drawing set '{record.Name}'", cancellationToken);
var saved = await ReadSet(response, cancellationToken);
if (saved.Id != record.Id || saved.Revision != 1 || !SameMembers(saved, record))
throw new IOException("The nest server returned a different drawing set than was saved. Reload the set before saving again.");
@@ -143,7 +136,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
if (record.Id != Guid.Empty && record.Id != id)
throw new ArgumentException("The set id does not match the set being saved.", nameof(record));
using var request = Guarded(HttpMethod.Put, $"api/sets/{id}", expectedRevision);
request.Content = JsonContent.Create(ForSave(record), options: JsonOptions);
request.Content = JsonContent.Create(ForSave(record), options: NestServerHttp.JsonOptions);
var saved = await SendGuarded(request, id, expectedRevision, $"save drawing set {id}", cancellationToken);
if (!SameMembers(saved, record))
throw new IOException("The nest server stored different set members. Reload the set before saving again.");
@@ -170,7 +163,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
throw new ArgumentException(error, nameof(release));
using var response = await _httpClient.PostAsync(Url("api/releases"),
JsonContent.Create(ForPost(release), options: JsonOptions), cancellationToken);
JsonContent.Create(ForPost(release), options: NestServerHttp.JsonOptions), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(ReleaseFeature);
if (response.StatusCode == HttpStatusCode.Conflict)
@@ -178,7 +171,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
// A server without drawings on their own refuses such a release as malformed.
if (response.StatusCode == HttpStatusCode.BadRequest && release.Drawings.Count > 0)
await EnsureDrawingReleasesSupportedAsync(cancellationToken);
await EnsureSuccess(response, "record the release", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "record the release", cancellationToken);
var stored = await ReadRelease(response, cancellationToken);
if (stored.Id != release.Id
|| !stored.Sets.Select(s => (s?.SetId, s?.SetRevision, s?.Quantity))
@@ -200,7 +193,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
await ThrowIfReleasesUnsupported(cancellationToken);
return null;
}
await EnsureSuccess(response, $"read release {id}", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"read release {id}", cancellationToken);
var release = await ReadRelease(response, cancellationToken);
if (release.Id != id)
throw new IOException("Nest server returned a different release.");
@@ -224,8 +217,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
// A server without drawings on their own refuses the unknown drawingId parameter.
if (response.StatusCode == HttpStatusCode.BadRequest && drawingId is not null)
throw new ServerTooOldException(DrawingReleaseFeature);
await EnsureSuccess(response, "read releases", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, "read releases", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty release page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| page.Items.Any(item => item is null || item.Sets is null || item.Drawings is null || item.Nests is null
@@ -245,7 +238,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
using var probe = await _httpClient.GetAsync(Url($"api/releases/query?drawingId={Guid.NewGuid()}&limit=1"), cancellationToken);
if (probe.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest)
throw new ServerTooOldException(DrawingReleaseFeature);
await EnsureSuccess(probe, "check the release support", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(probe, "check the release support", cancellationToken);
}
/// <summary>
@@ -262,7 +255,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
throw new ArgumentException(error, nameof(request));
using var response = await _httpClient.PostAsync(Url($"api/releases/{id}/undo"),
JsonContent.Create(request, options: JsonOptions), cancellationToken);
JsonContent.Create(request, options: NestServerHttp.JsonOptions), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfReleasesUnsupported(cancellationToken);
@@ -270,12 +263,12 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
}
if (response.StatusCode == HttpStatusCode.Conflict)
{
var conflict = await response.Content.ReadFromJsonAsync<SetReleaseUndoConflict>(JsonOptions, cancellationToken);
var conflict = await response.Content.ReadFromJsonAsync<SetReleaseUndoConflict>(NestServerHttp.JsonOptions, cancellationToken);
if (conflict?.Release is null || conflict.ChangedNests is null)
throw new IOException("Nest server refused the undo without saying why.");
throw new SetReleaseUndoConflictException(conflict);
}
await EnsureSuccess(response, $"undo release {id}", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"undo release {id}", cancellationToken);
var undone = await ReadRelease(response, cancellationToken);
if (undone.Id != id || !undone.IsUndone)
throw new IOException("Nest server did not report the release as undone.");
@@ -297,7 +290,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision)
{
var request = new HttpRequestMessage(method, Url(relative));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
NestServerHttp.AddIfMatch(request, expectedRevision);
return request;
}
@@ -312,7 +305,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
}
if (response.StatusCode == HttpStatusCode.Conflict)
throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken));
await EnsureSuccess(response, action, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
var saved = await ReadSet(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid drawing set revision. Reload the set before saving again.");
@@ -327,7 +320,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
await ThrowIfServerTooOld(cancellationToken);
return null;
}
await EnsureSuccess(response, action, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
return await ReadSet(response, cancellationToken);
}
@@ -341,8 +334,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
await ThrowIfServerTooOld(cancellationToken);
return null;
}
await EnsureSuccess(response, action, cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing set page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| page.Items.Any(item => item is null))
@@ -465,38 +458,18 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private static async Task<DrawingSetRecord> ReadSet(HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<DrawingSetRecord>(JsonOptions, cancellationToken);
var record = await response.Content.ReadFromJsonAsync<DrawingSetRecord>(NestServerHttp.JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty drawing set record.");
}
private static async Task<SetReleaseRecord> ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken)
{
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(JsonOptions, cancellationToken);
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(NestServerHttp.JsonOptions, cancellationToken);
if (release is null || release.Sets is null || release.Drawings is null || release.Nests is null)
throw new IOException("Nest server returned an empty set release record.");
return release;
}
private static async Task EnsureSuccess(HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
public void Dispose()
{
+24 -53
View File
@@ -16,13 +16,6 @@ namespace OpenNest.Data;
/// </summary>
public sealed class RemoteNestRepository : INestRepository, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
@@ -46,9 +39,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
await EnsureSuccess(response, "list nests", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "list nests", cancellationToken);
var items = await response.Content
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
.ReadFromJsonAsync<List<NestRecord>>(NestServerHttp.JsonOptions, cancellationToken);
return items ?? new List<NestRecord>();
}
@@ -73,8 +66,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
}
await EnsureSuccess(response, "query nests", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, "query nests", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty page.");
if (page.Items.Count > query.Limit)
{
@@ -115,8 +108,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.JsonOptions, cancellationToken);
}
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
@@ -130,7 +123,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
@@ -139,7 +132,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
{
using var content = BuildMultipart(nestFile, record);
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"upload nest '{record.Name}'", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
@@ -151,8 +144,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
Url("api/nest-names/next?year=" + year.ToString(CultureInfo.InvariantCulture)), null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not allocate nest names (HTTP 404).");
await EnsureSuccess(response, "allocate a nest name", cancellationToken);
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(JsonOptions, cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, "allocate a nest name", cancellationToken);
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(NestServerHttp.JsonOptions, cancellationToken);
if (allocation is null || allocation.Year != year || allocation.Number < 1)
throw new IOException("Nest server returned an invalid nest name allocation.");
return allocation.Number;
@@ -192,8 +185,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(JsonOptions, cancellationToken)
await NestServerHttp.EnsureSuccessAsync(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
@@ -216,7 +209,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
ValidateRevision(revisionNumber);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
@@ -232,10 +225,10 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
if (history.Items.Count == 0)
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
NestServerHttp.AddIfMatch(request, expectedRevision);
request.Content = BuildMultipart(nestFile, record);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
@@ -249,10 +242,10 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
request.Content = JsonContent.Create(record, options: JsonOptions);
NestServerHttp.AddIfMatch(request, expectedRevision);
request.Content = JsonContent.Create(record, options: NestServerHttp.JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"edit nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
@@ -265,9 +258,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post,
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"restore nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
@@ -286,9 +279,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
await NestServerHttp.EnsureSuccessAsync(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
@@ -312,7 +305,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
{
var content = new MultipartFormDataContent();
var metadata = new StringContent(
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
JsonSerializer.Serialize(record, NestServerHttp.JsonOptions), Encoding.UTF8, "application/json");
content.Add(metadata, "metadata");
var file = new ByteArrayContent(nestFile);
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
@@ -323,29 +316,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
private static async Task<NestRecord> ReadRecord(
HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
var record = await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty record.");
}
private static async Task EnsureSuccess(
HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ public sealed class SupportGridSettings
}
/// <summary>The grid these settings describe in <paramref name="units"/>, or null when it is invalid.</summary>
public SupportGrid GridFor(Units units)
public SupportGrid? GridFor(Units units)
{
var converted = ConvertedTo(units);
try
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
@@ -209,9 +209,9 @@ public class StockLadderTests
new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min)
);
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 0), sheet));
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 7), sheet));
Assert.Equal(70, NestJobCost.NetSheetArea(Job(0.5, 5), sheet), 6);
Assert.Throws<ArgumentOutOfRangeException>(() =>
new NestJobOptions(salvageRate: double.NaN)
);
@@ -276,7 +276,7 @@ public static class CuttingPlanService
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings));
fixedFindings.AddRange(JointCuttingPlanSearch.Map(snapshot, findings));
}
if (fixedFindings.Count != 0)
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
@@ -293,7 +293,7 @@ public static class CuttingPlanService
}
var search = FixedProgramSearch.Run(snapshot, token);
if (search.Status != CuttingPlanStatus.Ready)
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
return new(search.Status, findings: JointCuttingPlanSearch.Map(snapshot, search.Findings), expansions: search.Expansions);
return Replay(snapshot, search.Order, search.Expansions, token);
}
catch (OperationCanceledException)
@@ -330,7 +330,7 @@ public static class CuttingPlanService
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: Map(snapshot, findings), expansions: expansions);
findings: JointCuttingPlanSearch.Map(snapshot, findings), expansions: expansions);
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
@@ -489,14 +489,4 @@ public static class CuttingPlanService
private static bool SameBits(double source, double proposed) =>
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
{
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message)
{ Witness = finding };
});
}
@@ -1,16 +1,14 @@
using System;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Built-in remnant placement strategy for the whole-job runner. Fills one candidate per trial with
/// a <see cref="RemnantPlateFiller"/> for the vertical or horizontal remnant policy, on a fresh
/// private plate, using the same run-scoped identity, progress, and accounting boundaries as
/// <see cref="DefaultPlateNester"/>.
/// a <see cref="RemnantPlateFiller"/> for the vertical or horizontal remnant policy. Placement is
/// <see cref="DefaultPlateNester"/>'s with that filler: the same fresh private plate per trial and
/// the same run-scoped identity, progress, and accounting boundaries.
/// </summary>
/// <remarks>
/// The remnant fillers share the Fill pipeline's automatic-rotation limitation, so non-automatic
@@ -18,14 +16,12 @@ namespace OpenNest.Engine.Jobs.Placement;
/// </remarks>
public sealed class RemnantPlateNester : IPlateNester
{
private readonly Func<Plate, RemnantPlateFiller> fillerFactory;
private readonly OrderedPlateNester restrictedRotationNester = new();
private readonly CandidatePlacementContext context = new();
private readonly DefaultPlateNester nester;
internal RemnantPlateNester(Func<Plate, RemnantPlateFiller> fillerFactory)
{
this.fillerFactory =
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory));
nester = new DefaultPlateNester(
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)));
}
/// <summary>Vertical-remnant policy: minimize X-extent, prefer horizontal placement.</summary>
@@ -40,29 +36,5 @@ public sealed class RemnantPlateNester : IPlateNester
PlatePlacementRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(request);
token.ThrowIfCancellationRequested();
// Same safety rule as DefaultPlateNester: the remnant fillers inherit the Fill pipeline's
// automatic-rotation limitation, so any non-automatic requirement goes to the policy-aware
// ordered nester.
if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic))
return restrictedRotationNester.Place(request, progress, token);
var plate = DrawingJobMapper.CreatePlate(request.Stock);
var items = context.CreateItems(request.Parts);
var filler =
fillerFactory(plate)
?? throw new InvalidOperationException("Filler factory returned null.");
var candidateProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
var parts = filler.Nest(items, candidateProgress, token);
token.ThrowIfCancellationRequested();
if (parts == null)
throw new InvalidOperationException("Filler returned null placements.");
return new PlateCandidate(context.MapPlacements(parts));
}
) => nester.Place(request, progress, token);
}
+1 -27
View File
@@ -2,6 +2,7 @@ using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Breakoffs;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -81,31 +82,4 @@ public class BreakoffPersistenceTests
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
{
var reader = new NestReader(new MemoryStream(archive));
return (reader.Read(), reader.Warnings);
}
private static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
}
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
{
using var output = new MemoryStream();
output.Write(archive);
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
{
zip.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
return output.ToArray();
}
}
+1 -38
View File
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -518,42 +519,4 @@ public class ClampRailPersistenceTests
return false;
}
}
private static string ReadNestJson(MemoryStream zipBytes)
{
zipBytes.Position = 0;
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
var entry = archive.GetEntry("nest.json");
Assert.NotNull(entry);
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return reader.ReadToEnd();
}
private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
{
zipBytes.Position = 0;
var output = new MemoryStream();
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var newEntry = target.CreateEntry(entry.FullName);
using var input = entry.Open();
using var outStream = newEntry.Open();
if (entry.FullName == "nest.json")
{
using var text = new StreamReader(input, Encoding.UTF8);
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
writer.Write(rewrite(text.ReadToEnd()));
}
else
{
input.CopyTo(outStream);
}
}
}
output.Position = 0;
return output;
}
}
+1 -38
View File
@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Text;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -135,42 +136,4 @@ public class ClampZonePersistenceTests
Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal);
Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal);
}
private static string ReadNestJson(MemoryStream zipBytes)
{
zipBytes.Position = 0;
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
var entry = archive.GetEntry("nest.json");
Assert.NotNull(entry);
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return reader.ReadToEnd();
}
private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
{
zipBytes.Position = 0;
var output = new MemoryStream();
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var newEntry = target.CreateEntry(entry.FullName);
using var input = entry.Open();
using var outStream = newEntry.Open();
if (entry.FullName == "nest.json")
{
using var text = new StreamReader(input, Encoding.UTF8);
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
writer.Write(rewrite(text.ReadToEnd()));
}
else
{
input.CopyTo(outStream);
}
}
}
output.Position = 0;
return output;
}
}
+1 -27
View File
@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json.Nodes;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -193,37 +194,10 @@ public class CutPinPersistenceTests
return stream.ToArray();
}
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
{
var reader = new NestReader(new MemoryStream(archive));
return (reader.Read(), reader.Warnings);
}
private static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
}
private static byte[] ReplaceCutPins(byte[] archive, JsonNode? cutPins)
{
var json = NestJson(archive);
json["drawings"]![0]!["cutPins"] = cutPins;
return ReplaceNestJson(archive, json);
}
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
{
using var output = new MemoryStream();
output.Write(archive);
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
{
zip.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
return output.ToArray();
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
namespace OpenNest.IO.Tests;
/// <summary>
/// Reads and rewrites the <c>nest.json</c> entry of a .nest archive, for tests that check or
/// corrupt the stored shape. Import with <c>using static</c>.
/// </summary>
internal static class NestArchiveJson
{
public static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
{
var reader = new NestReader(new MemoryStream(archive));
return (reader.Read(), reader.Warnings);
}
public static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
}
public static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
{
using var output = new MemoryStream();
output.Write(archive);
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
{
zip.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
return output.ToArray();
}
public static string ReadNestJson(MemoryStream zipBytes)
{
zipBytes.Position = 0;
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
var entry = archive.GetEntry("nest.json");
Assert.NotNull(entry);
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return reader.ReadToEnd();
}
/// <summary>A copy of the archive whose nest.json text passed through <paramref name="rewrite"/>.</summary>
public static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
{
zipBytes.Position = 0;
var output = new MemoryStream();
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var newEntry = target.CreateEntry(entry.FullName);
using var input = entry.Open();
using var outStream = newEntry.Open();
if (entry.FullName == "nest.json")
{
using var text = new StreamReader(input, Encoding.UTF8);
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
writer.Write(rewrite(text.ReadToEnd()));
}
else
{
input.CopyTo(outStream);
}
}
}
output.Position = 0;
return output;
}
}
@@ -5,6 +5,7 @@ using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.TabbedCutouts;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -106,31 +107,4 @@ public class TabbedCutoutPersistenceTests
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
{
var reader = new NestReader(new MemoryStream(archive));
return (reader.Read(), reader.Warnings);
}
private static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
}
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
{
using var output = new MemoryStream();
output.Write(archive);
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
{
zip.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
return output.ToArray();
}
}
+32 -60
View File
@@ -8,14 +8,13 @@ using CSMath;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
namespace OpenNest.IO
{
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
using Layer = ACadSharp.Tables.Layer;
public static class Dxf
{
#region Import
@@ -43,64 +42,17 @@ namespace OpenNest.IO
return new DxfImportResult { Entities = entities, Document = doc };
}
public static List<Entity> GetGeometry(string path)
{
try
{
var doc = ReadDocument(path);
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(string path) =>
ReadGeometry(() => ReadDocument(path), null);
public static List<Entity> GetGeometry(Stream stream)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream) =>
GetGeometry(stream, null);
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter) =>
ReadGeometry(() => ReadDxf(new DxfReader(path)), layerFilter);
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter) =>
ReadGeometry(() => ReadDxf(new DxfReader(stream)), layerFilter);
#endregion
@@ -151,6 +103,26 @@ namespace OpenNest.IO
private static bool IsDwg(string path) =>
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
// An unreadable drawing yields no geometry rather than an exception.
private static List<Entity> ReadGeometry(Func<CadDocument> read, Func<string, bool> layerFilter)
{
try
{
return ConvertEntities(read(), layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
private static CadDocument ReadDxf(DxfReader reader)
{
using (reader)
return reader.Read();
}
private static CadDocument ReadDocument(string path)
{
if (IsDwg(path))
+2 -7
View File
@@ -249,15 +249,10 @@ public sealed partial class NestDatabase
lock (_sync)
{
if (GetDrawing(id) is null) return null;
using var count = _connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM drawing_revisions WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
var total = checked((int)(long)count.ExecuteScalar()!);
var total = CountRevisions("drawing_revisions", id);
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {DrawingColumns} FROM drawing_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
BindRevisionPage(command, id, offset, limit);
var records = new List<DrawingLibraryRecord>();
using var reader = command.ExecuteReader();
while (reader.Read()) records.Add(ReadDrawing(reader));
+2 -7
View File
@@ -186,18 +186,13 @@ public sealed partial class NestDatabase
lock (_sync)
{
if (GetDrawingSet(id) is null) return null;
using var count = _connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM drawing_set_revisions WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
var total = checked((int)(long)count.ExecuteScalar()!);
var total = CountRevisions("drawing_set_revisions", id);
using var command = _connection.CreateCommand();
command.CommandText = $"""
SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id
ORDER BY s.revision DESC LIMIT $limit OFFSET $offset
""";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
BindRevisionPage(command, id, offset, limit);
return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
}
}
+19 -7
View File
@@ -293,15 +293,10 @@ public sealed partial class NestDatabase : IDisposable
lock (_sync)
{
if (Get(id, deleted) is null) return null;
using var count = _connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM nest_revisions WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
var total = checked((int)(long)count.ExecuteScalar()!);
var total = CountRevisions("nest_revisions", id);
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
BindRevisionPage(command, id, offset, limit);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read()) records.Add(ReadRecord(reader));
@@ -513,6 +508,23 @@ public sealed partial class NestDatabase : IDisposable
+ (query.MaterialGrade is null ? "" : " AND shared_key(materialGrade)=$gradeKey")
+ (query.Thickness is null ? "" : " AND ABS(ROUND(thickness, 4) - $thickness) < 0.000001");
// Revisions of one id in a *_revisions table; call under _sync.
private int CountRevisions(string table, Guid id)
{
using var count = _connection.CreateCommand();
count.CommandText = $"SELECT COUNT(*) FROM {table} WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
return checked((int)(long)count.ExecuteScalar()!);
}
// Binds a newest-first revision page query's $id, $limit and $offset.
private static void BindRevisionPage(SqliteCommand command, Guid id, int offset, int limit)
{
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
}
private static void AddFilterParameters(SqliteCommand command, NestQuery query)
{
if (query.Status is { } status)
+4 -28
View File
@@ -35,9 +35,9 @@ internal static class NestQueryRequest
var sort = NestSortField.SavedAt;
if (values.TryGetValue("sort", out var sortText)
&& !TrySortField(sortText.ToString(), out sort))
&& !DrawingLibraryRequest.TryEnumName(sortText.ToString(), out sort))
{
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(CamelCase))}.";
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(DrawingLibraryRequest.CamelCase))}.";
return false;
}
@@ -80,7 +80,8 @@ internal static class NestQueryRequest
var offset = 0;
var limit = NestQuery.DefaultLimit;
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
if (!DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error)
|| !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error))
return false;
query = new NestQuery
@@ -99,29 +100,4 @@ internal static class NestQueryRequest
error = query.GetValidationError() ?? "";
return error.Length == 0;
}
// Names only: Enum.TryParse would also accept numbers and undefined values.
private static bool TrySortField(string text, out NestSortField field)
{
field = NestSortField.SavedAt;
return text.Length > 0
&& text.All(char.IsAsciiLetter)
&& Enum.TryParse(text, ignoreCase: true, out field)
&& Enum.IsDefined(field);
}
private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
{
error = "";
if (!values.TryGetValue(key, out var text))
return true;
if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
return true;
error = $"{key} must be a non-negative integer.";
return false;
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
namespace OpenNest.Controls
{
/// <summary>
/// Drives a grid that pages through a <see cref="BrowseSession{TPage, TSort}"/>: the summary
/// line, Previous/Next, sort glyphs and the failure text. The owner fills the rows
/// (<see cref="Populate"/>) and enables its own commands (<see cref="Settled"/>).
/// </summary>
internal sealed class BrowsePager<TPage, TSort>
where TPage : class, IBrowsePage
where TSort : struct, Enum
{
private const string LoadingText = "Loading...";
private readonly BrowseSession<TPage, TSort> session;
private readonly DataGridView grid;
private readonly Label summary;
private readonly Button previous;
private readonly Button next;
private readonly string listName;
/// <param name="listName">What failed to load, as in "Could not load the drawing sets".</param>
public BrowsePager(BrowseSession<TPage, TSort> session, DataGridView grid, Label summary,
Button previous, Button next, string listName)
{
this.session = session ?? throw new ArgumentNullException(nameof(session));
this.grid = grid;
this.summary = summary;
this.previous = previous;
this.next = next;
this.listName = listName;
}
/// <summary>Fills the grid from an applied page.</summary>
public Action<TPage> Populate { get; init; }
/// <summary>Runs after every request that leaves the owner open, to enable its commands.</summary>
public Action Settled { get; init; }
/// <summary>Runs after a failed request has cleared the grid.</summary>
public Action Failed { get; init; }
/// <summary>
/// Starts a session request and renders the page it applies. A failure clears the grid
/// and shows why in the summary line; a request that was declined or superseded renders
/// nothing of its own. Nothing is touched once the owner is disposed.
/// </summary>
public async Task RunAsync(Func<Task<bool>> request)
{
summary.Text = LoadingText;
try
{
var started = request();
UpdateNavigation();
if (await started && !grid.IsDisposed)
Populate?.Invoke(session.Page);
}
catch (Exception ex)
{
if (!grid.IsDisposed)
{
grid.Rows.Clear();
summary.Text = ex is ServerTooOldException
? ex.Message
: $"Could not load {listName}: {ex.Message}";
Failed?.Invoke();
}
}
finally
{
if (!grid.IsDisposed)
{
UpdateNavigation();
if (!session.IsLoading && summary.Text == LoadingText)
summary.Text = session.Summary;
Settled?.Invoke();
}
}
}
public void UpdateNavigation()
{
previous.Enabled = !session.IsLoading && session.CanGoPrevious;
next.Enabled = !session.IsLoading && session.CanGoNext;
}
/// <summary>Marks the column whose <see cref="DataGridViewColumn.Tag"/> is the session's sort field.</summary>
public void ShowSortGlyphs()
{
foreach (DataGridViewColumn column in grid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is TSort field && EqualityComparer<TSort>.Default.Equals(field, session.Sort)
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
}
/// <summary>Every selected row's record in list order; the current row's alone when nothing is selected.</summary>
public IReadOnlyList<TRecord> SelectedRecords<TRecord>()
where TRecord : class
{
var picked = grid.SelectedRows.Cast<DataGridViewRow>()
.OrderBy(row => row.Index)
.Select(row => row.Tag as TRecord)
.Where(record => record != null)
.ToList();
if (picked.Count == 0 && grid.CurrentRow?.Tag is TRecord current)
picked.Add(current);
return picked;
}
}
}
+11 -48
View File
@@ -18,8 +18,6 @@ namespace OpenNest.Controls
/// </summary>
public partial class DrawingSetBrowser : UserControl
{
private const string LoadingText = "Loading...";
private IDrawingSetStore sets;
private IDrawingLibraryStore library;
private string serverUrl;
@@ -27,6 +25,7 @@ namespace OpenNest.Controls
private Action<Nest, NestRecord> openNest;
private Func<Guid, bool> isNestOpen = _ => false;
private DrawingSetBrowseSession session;
private BrowsePager<DrawingSetPage, DrawingSetSortField> pager;
private bool pick;
private bool busy;
@@ -84,6 +83,13 @@ namespace OpenNest.Controls
this.serverUrl = serverUrl ?? "";
this.pick = pick;
session = new DrawingSetBrowseSession(sets);
Disposed += (_, _) => session.Dispose();
pager = new BrowsePager<DrawingSetPage, DrawingSetSortField>(
session, setGrid, summaryLabel, previousButton, nextButton, "the drawing sets")
{
Populate = Populate,
Settled = UpdateCommands,
};
ShowMessage = (text, buttons, icon) => MessageBox.Show(FindForm(), text, FindForm()?.Text ?? "Drawing Sets", buttons, icon);
EditSet = edit =>
{
@@ -116,7 +122,7 @@ namespace OpenNest.Controls
releasesButton.Visible = false;
}
UpdateNavigation();
pager.UpdateNavigation();
UpdateCommands();
}
@@ -396,38 +402,7 @@ namespace OpenNest.Controls
ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private async Task RunAsync(Func<Task<bool>> request)
{
summaryLabel.Text = LoadingText;
try
{
var started = request();
UpdateNavigation();
if (await started && !IsDisposed)
Populate(session.Page);
}
catch (Exception ex)
{
if (!IsDisposed)
{
setGrid.Rows.Clear();
summaryLabel.Text = ex is ServerTooOldException
? ex.Message
: $"Could not load the drawing sets: {ex.Message}";
}
}
finally
{
if (!IsDisposed)
{
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!session.IsLoading && summaryLabel.Text == LoadingText)
summaryLabel.Text = session.Summary;
UpdateCommands();
}
}
}
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
private void Populate(DrawingSetPage page)
{
@@ -452,22 +427,10 @@ namespace OpenNest.Controls
if (setGrid.CurrentCell == null && setGrid.Rows.Count > 0)
setGrid.CurrentCell = setGrid.Rows[0].Cells[0];
foreach (DataGridViewColumn column in setGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSetSortField field && field == session.Sort
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
pager.ShowSortGlyphs();
notesBox.Text = SelectedSet?.Notes ?? "";
}
private void UpdateNavigation()
{
previousButton.Enabled = session is { IsLoading: false, CanGoPrevious: true };
nextButton.Enabled = session is { IsLoading: false, CanGoNext: true };
}
private void UpdateCommands()
{
var selected = SelectedSet;
+12 -59
View File
@@ -19,9 +19,8 @@ namespace OpenNest.Forms
/// </summary>
public partial class AddReleaseDrawingsForm : Form
{
private const string LoadingText = "Loading...";
private readonly DrawingLibraryBrowseSession librarySession;
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> libraryPager;
private readonly Func<IReadOnlyList<DrawingLibraryRecord>, int, string> add;
private readonly Func<Guid, bool> isListed;
@@ -36,6 +35,13 @@ namespace OpenNest.Forms
this.isListed = isListed ?? throw new ArgumentNullException(nameof(isListed));
// A release mixes materials and thicknesses, so the library is never filtered to a nest.
librarySession = new DrawingLibraryBrowseSession(library, new Nest());
Disposed += (_, _) => librarySession.Dispose();
libraryPager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
librarySession, libraryGrid, librarySummaryLabel, previousButton, nextButton, "the drawing library")
{
Populate = PopulateLibrary,
Settled = UpdateAddCommand,
};
GridStyle.Apply(libraryGrid);
libraryNameColumn.Tag = DrawingSortField.Name;
@@ -128,51 +134,10 @@ namespace OpenNest.Forms
/// <summary>The status line, for tests.</summary>
internal string Status => statusLabel.Text;
// Every selected row in list order; the current row alone when nothing is selected.
private IReadOnlyList<DrawingLibraryRecord> SelectedDrawings()
{
var picked = libraryGrid.SelectedRows.Cast<DataGridViewRow>()
.OrderBy(row => row.Index)
.Select(row => row.Tag as DrawingLibraryRecord)
.Where(record => record != null)
.ToList();
if (picked.Count == 0 && libraryGrid.CurrentRow?.Tag is DrawingLibraryRecord current)
picked.Add(current);
return picked;
}
private IReadOnlyList<DrawingLibraryRecord> SelectedDrawings() =>
libraryPager.SelectedRecords<DrawingLibraryRecord>();
private async Task RunLibraryAsync(Func<Task<bool>> request)
{
librarySummaryLabel.Text = LoadingText;
try
{
var started = request();
UpdateLibraryNavigation();
if (await started && !IsDisposed)
PopulateLibrary(librarySession.Page);
}
catch (Exception ex)
{
if (!IsDisposed)
{
libraryGrid.Rows.Clear();
librarySummaryLabel.Text = ex is ServerTooOldException
? ex.Message
: $"Could not load the drawing library: {ex.Message}";
}
}
finally
{
if (!IsDisposed)
{
UpdateLibraryNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!librarySession.IsLoading && librarySummaryLabel.Text == LoadingText)
librarySummaryLabel.Text = librarySession.Summary;
UpdateAddCommand();
}
}
}
private Task RunLibraryAsync(Func<Task<bool>> request) => libraryPager.RunAsync(request);
private void PopulateLibrary(DrawingLibraryPage page)
{
@@ -189,13 +154,7 @@ namespace OpenNest.Forms
libraryGrid.Rows[index].Tag = record;
}
foreach (DataGridViewColumn column in libraryGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == librarySession.Sort
? librarySession.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
libraryPager.ShowSortGlyphs();
MarkListed();
UpdateAddCommand();
}
@@ -211,12 +170,6 @@ namespace OpenNest.Forms
}
}
private void UpdateLibraryNavigation()
{
previousButton.Enabled = !librarySession.IsLoading && librarySession.CanGoPrevious;
nextButton.Enabled = !librarySession.IsLoading && librarySession.CanGoNext;
}
private void UpdateAddCommand() => addButton.Enabled = SelectedDrawings().Count > 0;
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ namespace OpenNest.Forms
DropDownStyle = ComboBoxStyle.DropDownList;
Width = 200;
var machines = Sorted(MachineSettings.LoadMachines());
var machines = SortedByName(MachineSettings.LoadMachines());
Items.Add(new Choice(null));
foreach (var machine in machines)
Items.Add(new Choice(machine));
@@ -36,13 +36,13 @@ namespace OpenNest.Forms
SelectedItem = Items.Cast<Choice>().First(c => c.Machine == saved);
}
private static List<MachineConfig> Sorted(IEnumerable<MachineConfig> machines) =>
private static List<MachineConfig> SortedByName(IEnumerable<MachineConfig> machines) =>
machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
/// <summary>The remembered machine, the first one until a choice is saved, or null for full speed.</summary>
public static MachineConfig SavedMachine(IEnumerable<MachineConfig> machines)
{
var sorted = Sorted(machines);
var sorted = SortedByName(machines);
var saved = Properties.Settings.Default.CutTimeMachine;
return saved == ConstantSpeed
? null
+11 -47
View File
@@ -30,7 +30,6 @@ namespace OpenNest.Forms
/// </summary>
public partial class DrawingLibraryForm : Form
{
private const string LoadingText = "Loading...";
private const int DetailsWidth = 380;
private const int PreviewCacheLimit = 64;
private const int MaxQuantity = 99999;
@@ -40,6 +39,7 @@ namespace OpenNest.Forms
private readonly Nest nest;
private readonly IDrawingSetStore sets;
private readonly DrawingLibraryBrowseSession session;
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> pager;
private readonly Dictionary<(Guid Id, int Revision), Drawing> previews = new();
private CancellationTokenSource previewCancel;
private (Guid Id, int Revision) previewKey;
@@ -103,6 +103,13 @@ namespace OpenNest.Forms
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
this.sets = sets;
session = new DrawingLibraryBrowseSession(library, nest);
Disposed += (_, _) => session.Dispose();
pager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
session, drawingGrid, summaryLabel, previousButton, nextButton, "the drawing library")
{
Populate = Populate,
Settled = UpdateCommands,
};
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
EditDrawing = edit =>
{
@@ -147,7 +154,7 @@ namespace OpenNest.Forms
drawingGrid.ContextMenuStrip = drawingMenu;
showAllCheckBox.Enabled = session.IsFilteredToNest;
UpdateFilterLabel();
UpdateNavigation();
pager.UpdateNavigation();
ShowDetails();
RefreshNestTab();
}
@@ -670,38 +677,7 @@ namespace OpenNest.Forms
ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private async Task RunAsync(Func<Task<bool>> request)
{
summaryLabel.Text = LoadingText;
try
{
var started = request();
UpdateNavigation();
if (await started && !IsDisposed)
Populate(session.Page);
}
catch (Exception ex)
{
if (!IsDisposed)
{
drawingGrid.Rows.Clear();
summaryLabel.Text = ex is ServerTooOldException
? ex.Message
: $"Could not load the drawing library: {ex.Message}";
}
}
finally
{
if (!IsDisposed)
{
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!session.IsLoading && summaryLabel.Text == LoadingText)
summaryLabel.Text = session.Summary;
UpdateCommands();
}
}
}
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
private void Populate(DrawingLibraryPage page)
{
@@ -721,25 +697,13 @@ namespace OpenNest.Forms
drawingGrid.Rows[index].Tag = record;
}
foreach (DataGridViewColumn column in drawingGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == session.Sort
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
pager.ShowSortGlyphs();
ShowDetails();
}
private DrawingLibraryRecord SelectedRecord() =>
drawingGrid.CurrentRow?.Tag as DrawingLibraryRecord;
private void UpdateNavigation()
{
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
}
private void UpdateCommands()
{
addButton.Enabled = !adding && SelectedRecord() != null;
+11 -59
View File
@@ -23,10 +23,9 @@ namespace OpenNest.Forms
/// </summary>
public partial class EditDrawingSetForm : Form
{
private const string LoadingText = "Loading...";
private readonly DrawingSetEditSession session;
private readonly DrawingLibraryBrowseSession librarySession;
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> libraryPager;
private bool loading;
private bool saving;
@@ -44,6 +43,12 @@ namespace OpenNest.Forms
// A set mixes materials and thicknesses, so the library is never filtered to a nest.
librarySession = new DrawingLibraryBrowseSession(library, new Nest());
Disposed += (_, _) => librarySession.Dispose();
libraryPager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
librarySession, libraryGrid, librarySummaryLabel, previousButton, nextButton, "the drawing library")
{
Populate = PopulateLibrary,
Settled = UpdateAddCommand,
};
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
GridStyle.Apply(memberGrid);
@@ -217,51 +222,10 @@ namespace OpenNest.Forms
: $"{added} Already in the set: {string.Join(", ", result.AlreadyMembers.Select(d => $"'{d.Name}'"))}.";
}
// Every selected row in list order; the current row alone when nothing is selected.
private IReadOnlyList<DrawingLibraryRecord> SelectedLibraryDrawings()
{
var picked = libraryGrid.SelectedRows.Cast<DataGridViewRow>()
.OrderBy(row => row.Index)
.Select(row => row.Tag as DrawingLibraryRecord)
.Where(record => record != null)
.ToList();
if (picked.Count == 0 && libraryGrid.CurrentRow?.Tag is DrawingLibraryRecord current)
picked.Add(current);
return picked;
}
private IReadOnlyList<DrawingLibraryRecord> SelectedLibraryDrawings() =>
libraryPager.SelectedRecords<DrawingLibraryRecord>();
private async Task RunLibraryAsync(Func<Task<bool>> request)
{
librarySummaryLabel.Text = LoadingText;
try
{
var started = request();
UpdateLibraryNavigation();
if (await started && !IsDisposed)
PopulateLibrary(librarySession.Page);
}
catch (Exception ex)
{
if (!IsDisposed)
{
libraryGrid.Rows.Clear();
librarySummaryLabel.Text = ex is ServerTooOldException
? ex.Message
: $"Could not load the drawing library: {ex.Message}";
}
}
finally
{
if (!IsDisposed)
{
UpdateLibraryNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!librarySession.IsLoading && librarySummaryLabel.Text == LoadingText)
librarySummaryLabel.Text = librarySession.Summary;
UpdateAddCommand();
}
}
}
private Task RunLibraryAsync(Func<Task<bool>> request) => libraryPager.RunAsync(request);
private void PopulateLibrary(DrawingLibraryPage page)
{
@@ -277,13 +241,7 @@ namespace OpenNest.Forms
libraryGrid.Rows[index].Tag = record;
}
foreach (DataGridViewColumn column in libraryGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == librarySession.Sort
? librarySession.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
libraryPager.ShowSortGlyphs();
MarkMembersInLibrary();
UpdateAddCommand();
}
@@ -300,12 +258,6 @@ namespace OpenNest.Forms
}
}
private void UpdateLibraryNavigation()
{
previousButton.Enabled = !librarySession.IsLoading && librarySession.CanGoPrevious;
nextButton.Enabled = !librarySession.IsLoading && librarySession.CanGoNext;
}
private void UpdateAddCommand() =>
addButton.Enabled = !saving && SelectedLibraryDrawings().Count > 0;
+9 -43
View File
@@ -31,7 +31,6 @@ public sealed class SavedNestsForm : Form
private const string IdColumn = "Id";
private const string PlateSizeColumn = "PlateSize";
private const string AreaColumn = "Area";
private const string LoadingText = "Loading...";
private const string LoadingDetailsText = "Loading details...";
private const string WindowTitle = "Open Nest — Database";
@@ -40,6 +39,7 @@ public sealed class SavedNestsForm : Form
private readonly INestRepository repository;
private readonly string serverUrl;
private readonly NestBrowseSession session;
private readonly BrowsePager<NestPage, NestSortField> pager;
private NestDetailsSession details;
private readonly SplitContainer split;
private readonly DataGridView nestGrid;
@@ -289,6 +289,12 @@ public sealed class SavedNestsForm : Form
nextButton = new Button { Text = "\u25B6", Width = 32, Enabled = false };
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
summaryLabel = new Label { AutoSize = true, Margin = new Padding(12, 8, 3, 0) };
pager = new BrowsePager<NestPage, NestSortField>(
session, nestGrid, summaryLabel, previousButton, nextButton, "nests from the server")
{
Populate = Populate,
Failed = () => UpdateTitle(""),
};
var findBar = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
findBar.Controls.Add(new Label { Text = "Find:", AutoSize = true, Margin = new Padding(3, 8, 0, 0) });
@@ -429,42 +435,7 @@ public sealed class SavedNestsForm : Form
/// renders nothing; a failure clears the rows and shows the error in the summary line.
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
/// </summary>
private async Task RunAsync(Func<Task<bool>> request)
{
summaryLabel.Text = LoadingText;
try
{
var started = request();
UpdateNavigation();
if (await started && !IsDisposed)
Populate(session.Page);
}
catch (Exception ex)
{
if (!IsDisposed)
{
nestGrid.Rows.Clear();
summaryLabel.Text = $"Could not load nests from the server: {ex.Message}";
UpdateTitle("");
}
}
finally
{
if (!IsDisposed)
{
UpdateNavigation();
// A request that was declined or superseded renders nothing of its own.
if (!session.IsLoading && summaryLabel.Text == LoadingText)
summaryLabel.Text = session.Summary;
}
}
}
private void UpdateNavigation()
{
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
}
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
private void UpdateCommands()
{
@@ -509,12 +480,7 @@ public sealed class SavedNestsForm : Form
nestGrid.Rows[index].HeaderCell.Value = (page.Offset + i + 1).ToString("N0", CultureInfo.CurrentCulture);
}
foreach (DataGridViewColumn column in nestGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
: SortOrder.None;
}
pager.ShowSortGlyphs();
if (nestGrid.Rows.Count > 0 && nestGrid.CurrentCell == null)
nestGrid.CurrentCell = nestGrid.Rows[0].Cells[0];