diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs index e09d3ac7..95a76576 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs @@ -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 - { - 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); } } diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcApproach.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcApproach.cs new file mode 100644 index 00000000..b46a97ca --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcApproach.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + /// + /// A straight line into an arc that ends on the contour start, shared by + /// and . 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. + /// + 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))); + } + + /// Rapid to the pierce point, then the line and arc on the lead-in layer. + public List ToCodes(Vector contourStartPoint, RotationType winding) => + new List + { + new RapidMove(PiercePoint), + new LinearMove(ArcStart) { Layer = LayerType.Leadin }, + new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin }, + }; + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs index fdf620b0..0c37972e 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs @@ -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 - { - 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); } } diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index e2ff9751..8eca5685 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -131,7 +131,7 @@ namespace OpenNest /// Raised after content changes so views can refresh /// the zone overlay. Carries no geometry arguments. /// - public event EventHandler? ClampZonesChanged; + public event EventHandler ClampZonesChanged; private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty); diff --git a/OpenNest.Data/BrowseSession.cs b/OpenNest.Data/BrowseSession.cs new file mode 100644 index 00000000..478e0aaa --- /dev/null +++ b/OpenNest.Data/BrowseSession.cs @@ -0,0 +1,196 @@ +using System.Globalization; + +namespace OpenNest.Data; + +/// The paging facts a reads from a server page. +public interface IBrowsePage +{ + int Offset { get; } + + /// Number of records matching the request across all pages. + int Total { get; } + + /// Number of records on this page. + int ItemCount { get; } +} + +/// +/// 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). +/// +public abstract class BrowseSession : 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; } + + /// Trimmed search text of the latest request. + public string Search { get; private set; } = ""; + + public TSort Sort { get; private set; } + + public bool Descending { get; private set; } + + /// Offset of the latest request. + public int Offset { get; private set; } + + /// The latest applied page; null before the first result and after a failed request. + public TPage? Page { get; private set; } + + /// True while the latest request is outstanding; superseded requests do not count. + public bool IsLoading { get; private set; } + + public bool CanGoPrevious => Offset > 0; + + public bool CanGoNext => Page is { } page && page.Offset + page.ItemCount < page.Total; + + /// Operator-facing summary of , such as "Showing 101-200 of 1,234 nests". + public abstract string Summary { get; } + + /// Applies new search text and returns to the first page. + public Task SetSearchAsync(string? search) + { + Search = (search ?? "").Trim(); + return FirstPageAsync(); + } + + /// Sorts by a column: a new column starts ascending, the same column reverses. + public Task SortByAsync(TSort field) + { + Descending = EqualityComparer.Default.Equals(field, Sort) && !Descending; + Sort = field; + return FirstPageAsync(); + } + + public Task NextPageAsync() + { + if (!CanGoNext) + return Task.FromResult(false); + + Offset = Page!.Offset + PageSize; + return RunAsync(); + } + + public Task PreviousPageAsync() + { + if (!CanGoPrevious) + return Task.FromResult(false); + + Offset = System.Math.Max(0, Offset - PageSize); + return RunAsync(); + } + + /// Re-reads the current page; steps back to the last page if it is now past the end. + public Task RefreshAsync() => RunAsync(); + + /// Re-queries from the first page, after a derived filter changed. + protected Task FirstPageAsync() + { + Offset = 0; + return RunAsync(); + } + + /// Queries the page at with the current filters. + protected abstract Task QueryAsync(CancellationToken cancellationToken); + + /// The standard summary: when nothing matches, else the shown range of . + 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}"; + } + + /// + /// Sends the current request. Returns true when its page was applied, false when a later + /// request superseded it. Failures of the latest request clear and propagate; + /// failures of superseded requests are ignored. + /// + private async Task 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); + + /// + /// Cancels any request in flight (which releases its own source when it ends); + /// the server client is not owned and stays open. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _generation++; + IsLoading = false; + var pending = _pending; + _pending = null; + pending?.Cancel(); + } +} diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs index 160dba0c..077a9904 100644 --- a/OpenNest.Data/DrawingLibraryBrowseSession.cs +++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs @@ -1,32 +1,23 @@ -using System.Globalization; -using OpenNest; - namespace OpenNest.Data; /// /// 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; 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 . +/// Call from one thread (the UI thread). /// -public sealed class DrawingLibraryBrowseSession : IDisposable +public sealed class DrawingLibraryBrowseSession : BrowseSession { 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; } - /// The nest's material name, applied unless ; blank means any. 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); - /// Trimmed search text of the latest request. - public string Search { get; private set; } = ""; - - public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt; - - public bool Descending { get; private set; } = true; - - /// Offset of the latest request. - public int Offset { get; private set; } - - /// The latest applied page; null before the first result and after a failed request. - public DrawingLibraryPage? Page { get; private set; } - - /// True while the latest request is outstanding; superseded requests do not count. - public bool IsLoading { get; private set; } - - public bool CanGoPrevious => Offset > 0; - - public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total; - - /// Operator-facing summary of , such as "Showing 101-200 of 1,234 drawings". - 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"; - } - } - - /// Applies new search text and returns to the first page. - public Task SetSearchAsync(string? search) - { - Search = (search ?? "").Trim(); - Offset = 0; - return RunAsync(); - } + /// + 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."); /// Lifts or restores the nest filter and returns to the first page. public Task SetShowAllAsync(bool showAll) { ShowAll = showAll; - Offset = 0; - return RunAsync(); + return FirstPageAsync(); } - /// Sorts by a column: a new column starts ascending, the same column reverses. - public Task SortByAsync(DrawingSortField field) - { - Descending = field == Sort && !Descending; - Sort = field; - Offset = 0; - return RunAsync(); - } - - public Task NextPageAsync() - { - if (!CanGoNext) - return Task.FromResult(false); - - Offset = Page!.Offset + PageSize; - return RunAsync(); - } - - public Task PreviousPageAsync() - { - if (!CanGoPrevious) - return Task.FromResult(false); - - Offset = System.Math.Max(0, Offset - PageSize); - return RunAsync(); - } - - /// Re-reads the current page; steps back to the last page if it is now past the end. - public Task RefreshAsync() => RunAsync(); - - /// - /// Sends the current request. Returns true when its page was applied, false when a later - /// request superseded it. Failures of the latest request clear and propagate; - /// failures of superseded requests are ignored. - /// - private async Task 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 QueryAsync(CancellationToken cancellationToken) => + protected override Task 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); - - /// - /// Cancels any request in flight (which releases its own source when it ends); - /// the library client is not owned and stays open. - /// - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - _generation++; - IsLoading = false; - var pending = _pending; - _pending = null; - pending?.Cancel(); - } } diff --git a/OpenNest.Data/DrawingLibraryPage.cs b/OpenNest.Data/DrawingLibraryPage.cs index 809b9f52..41408d35 100644 --- a/OpenNest.Data/DrawingLibraryPage.cs +++ b/OpenNest.Data/DrawingLibraryPage.cs @@ -1,7 +1,7 @@ namespace OpenNest.Data; /// One bounded page of library drawing metadata. -public sealed class DrawingLibraryPage +public sealed class DrawingLibraryPage : IBrowsePage { /// At most records, in the requested order. public IReadOnlyList Items { get; init; } = Array.Empty(); @@ -12,4 +12,6 @@ public sealed class DrawingLibraryPage public int Offset { get; init; } public int Limit { get; init; } + + int IBrowsePage.ItemCount => Items.Count; } diff --git a/OpenNest.Data/DrawingSetBrowseSession.cs b/OpenNest.Data/DrawingSetBrowseSession.cs index 137c4fc0..21b9a9a9 100644 --- a/OpenNest.Data/DrawingSetBrowseSession.cs +++ b/OpenNest.Data/DrawingSetBrowseSession.cs @@ -1,171 +1,36 @@ -using System.Globalization; - namespace OpenNest.Data; /// /// Browse state for Nest > 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 +/// . Call from one thread (the UI thread). /// -public sealed class DrawingSetBrowseSession : IDisposable +public sealed class DrawingSetBrowseSession : BrowseSession { 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; } - - /// Trimmed search text of the latest request. - public string Search { get; private set; } = ""; - /// True when archived sets are listed with the active ones. public bool IncludeArchived { get; private set; } - public DrawingSetSortField Sort { get; private set; } = DrawingSetSortField.SavedAt; - - public bool Descending { get; private set; } = true; - - /// Offset of the latest request. - public int Offset { get; private set; } - - /// The latest applied page; null before the first result and after a failed request. - public DrawingSetPage? Page { get; private set; } - - /// True while the latest request is outstanding; superseded requests do not count. - public bool IsLoading { get; private set; } - - public bool CanGoPrevious => Offset > 0; - - public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total; - - /// Operator-facing summary of , such as "Showing 1-100 of 240 sets". - 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"; - } - } - - /// Applies new search text and returns to the first page. - public Task SetSearchAsync(string? search) - { - Search = (search ?? "").Trim(); - Offset = 0; - return RunAsync(); - } + /// + public override string Summary => + PageSummary("sets", Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one."); /// Lists or hides archived sets and returns to the first page. public Task SetIncludeArchivedAsync(bool includeArchived) { IncludeArchived = includeArchived; - Offset = 0; - return RunAsync(); + return FirstPageAsync(); } - /// Sorts by a column: a new column starts ascending, the same column reverses. - public Task SortByAsync(DrawingSetSortField field) - { - Descending = field == Sort && !Descending; - Sort = field; - Offset = 0; - return RunAsync(); - } - - public Task NextPageAsync() - { - if (!CanGoNext) - return Task.FromResult(false); - - Offset = Page!.Offset + PageSize; - return RunAsync(); - } - - public Task PreviousPageAsync() - { - if (!CanGoPrevious) - return Task.FromResult(false); - - Offset = System.Math.Max(0, Offset - PageSize); - return RunAsync(); - } - - /// Re-reads the current page; steps back to the last page if it is now past the end. - public Task RefreshAsync() => RunAsync(); - - /// - /// Sends the current request. Returns true when its page was applied, false when a later - /// request superseded it. Failures of the latest request clear and propagate; - /// failures of superseded requests are ignored. - /// - private async Task 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 QueryAsync(CancellationToken cancellationToken) => + protected override Task 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); - - /// - /// Cancels any request in flight (which releases its own source when it ends); - /// the set client is not owned and stays open. - /// - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - _generation++; - IsLoading = false; - var pending = _pending; - _pending = null; - pending?.Cancel(); - } } diff --git a/OpenNest.Data/DrawingSetPage.cs b/OpenNest.Data/DrawingSetPage.cs index 038c3abb..feffb450 100644 --- a/OpenNest.Data/DrawingSetPage.cs +++ b/OpenNest.Data/DrawingSetPage.cs @@ -1,7 +1,7 @@ namespace OpenNest.Data; /// One bounded page of drawing set records, without their members. -public sealed class DrawingSetPage +public sealed class DrawingSetPage : IBrowsePage { /// At most records, in the requested order. public IReadOnlyList Items { get; init; } = Array.Empty(); @@ -12,4 +12,6 @@ public sealed class DrawingSetPage public int Offset { get; init; } public int Limit { get; init; } + + int IBrowsePage.ItemCount => Items.Count; } diff --git a/OpenNest.Data/NestBrowseSession.cs b/OpenNest.Data/NestBrowseSession.cs index 57ec19f6..04cc4640 100644 --- a/OpenNest.Data/NestBrowseSession.cs +++ b/OpenNest.Data/NestBrowseSession.cs @@ -1,163 +1,27 @@ -using System.Globalization; - namespace OpenNest.Data; /// /// 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 +/// . Call from one thread (the UI thread). /// -public sealed class NestBrowseSession : IDisposable +public sealed class NestBrowseSession : BrowseSession { 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; } + /// + public override string Summary => + PageSummary("nests", Search.Length == 0 ? "No nests on the server." : "No nests match the filter."); - /// Trimmed search text of the latest request. - public string Search { get; private set; } = ""; - - public NestSortField Sort { get; private set; } = NestSortField.SavedAt; - - public bool Descending { get; private set; } = true; - - /// Offset of the latest request. - public int Offset { get; private set; } - - /// The latest applied page; null before the first result and after a failed request. - public NestPage? Page { get; private set; } - - /// True while the latest request is outstanding; superseded requests do not count. - public bool IsLoading { get; private set; } - - public bool CanGoPrevious => Offset > 0; - - public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total; - - /// Operator-facing summary of , such as "Showing 101-200 of 1,234 nests". - 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"; - } - } - - /// Applies new search text and returns to the first page. - public Task SetSearchAsync(string? search) - { - Search = (search ?? "").Trim(); - Offset = 0; - return RunAsync(); - } - - /// Sorts by a column: a new column starts ascending, the same column reverses. - public Task SortByAsync(NestSortField field) - { - Descending = field == Sort && !Descending; - Sort = field; - Offset = 0; - return RunAsync(); - } - - public Task NextPageAsync() - { - if (!CanGoNext) - return Task.FromResult(false); - - Offset = Page!.Offset + PageSize; - return RunAsync(); - } - - public Task PreviousPageAsync() - { - if (!CanGoPrevious) - return Task.FromResult(false); - - Offset = System.Math.Max(0, Offset - PageSize); - return RunAsync(); - } - - /// Re-reads the current page; steps back to the last page if it is now past the end. - public Task RefreshAsync() => RunAsync(); - - /// - /// Sends the current request. Returns true when its page was applied, false when a later - /// request superseded it. Failures of the latest request clear and propagate; - /// failures of superseded requests are ignored. - /// - private async Task 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 QueryAsync(CancellationToken cancellationToken) => + protected override Task 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); - - /// - /// Cancels any request in flight (which releases its own source when it ends); - /// the repository is not owned and stays open. - /// - public void Dispose() - { - if (_disposed) - return; - - _disposed = true; - _generation++; - IsLoading = false; - var pending = _pending; - _pending = null; - pending?.Cancel(); - } } diff --git a/OpenNest.Data/NestPage.cs b/OpenNest.Data/NestPage.cs index 917bd238..8381b8db 100644 --- a/OpenNest.Data/NestPage.cs +++ b/OpenNest.Data/NestPage.cs @@ -1,7 +1,7 @@ namespace OpenNest.Data; /// One page of saved-nest metadata returned by GET /api/nests/query. -public sealed class NestPage +public sealed class NestPage : IBrowsePage { /// At most matching records, in the requested order. public IReadOnlyList Items { get; init; } = Array.Empty(); @@ -12,4 +12,6 @@ public sealed class NestPage public int Offset { get; init; } public int Limit { get; init; } + + int IBrowsePage.ItemCount => Items.Count; } diff --git a/OpenNest.Data/NestServerHttp.cs b/OpenNest.Data/NestServerHttp.cs new file mode 100644 index 00000000..c29ffb63 --- /dev/null +++ b/OpenNest.Data/NestServerHttp.cs @@ -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; + +/// Wire conventions shared by the nest server's HTTP clients. +internal static class NestServerHttp +{ + /// The server's JSON shape: camel-case names and enum names. + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + /// Guards a write on the revision the caller last read. + public static void AddIfMatch(HttpRequestMessage request, int expectedRevision) => + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + + /// Throws an naming the action, status and the start of the body. + 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()); + } +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 2d01a7ad..926bee27 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -18,13 +18,6 @@ namespace OpenNest.Data; /// 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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, "browse the drawing library", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing history {id}", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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 ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) { - var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var record = await response.Content.ReadFromJsonAsync(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() { diff --git a/OpenNest.Data/RemoteDrawingSetStore.cs b/OpenNest.Data/RemoteDrawingSetStore.cs index 80edfe42..d97d287b 100644 --- a/OpenNest.Data/RemoteDrawingSetStore.cs +++ b/OpenNest.Data/RemoteDrawingSetStore.cs @@ -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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, "browse drawing sets", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, "read releases", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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); } /// @@ -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(JsonOptions, cancellationToken); + var conflict = await response.Content.ReadFromJsonAsync(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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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 ReadSet(HttpResponseMessage response, CancellationToken cancellationToken) { - var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var record = await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken); return record ?? throw new IOException("Nest server returned an empty drawing set record."); } private static async Task ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken) { - var release = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var release = await response.Content.ReadFromJsonAsync(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() { diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index 906cbaa7..bb25a1f1 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -16,13 +16,6 @@ namespace OpenNest.Data; /// 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> 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>(JsonOptions, cancellationToken); + .ReadFromJsonAsync>(NestServerHttp.JsonOptions, cancellationToken); return items ?? new List(); } @@ -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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, "query nests", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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(JsonOptions, cancellationToken); + await NestServerHttp.EnsureSuccessAsync(response, $"get nest {id}", cancellationToken); + return await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken); } public Task 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(JsonOptions, cancellationToken); + await NestServerHttp.EnsureSuccessAsync(response, "allocate a nest name", cancellationToken); + var allocation = await response.Content.ReadFromJsonAsync(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(JsonOptions, cancellationToken) + await NestServerHttp.EnsureSuccessAsync(response, $"read nest history {id}", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(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 ReadRecord( HttpResponseMessage response, CancellationToken cancellationToken) { - var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var record = await response.Content.ReadFromJsonAsync(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()); - } } diff --git a/OpenNest.Data/SupportGridSettings.cs b/OpenNest.Data/SupportGridSettings.cs index 247a5c2d..71676bb7 100644 --- a/OpenNest.Data/SupportGridSettings.cs +++ b/OpenNest.Data/SupportGridSettings.cs @@ -60,7 +60,7 @@ public sealed class SupportGridSettings } /// The grid these settings describe in , or null when it is invalid. - public SupportGrid GridFor(Units units) + public SupportGrid? GridFor(Units units) { var converted = ConvertedTo(units); try diff --git a/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs index 459d3e4a..3157fa8c 100644 --- a/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs +++ b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs @@ -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(() => new NestJobOptions(salvageRate: double.NaN) ); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index cb2973d9..9a88e30b 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -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 Map(CuttingPlanSnapshot snapshot, - IEnumerable 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 }; - }); } diff --git a/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs b/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs index 368d3680..876eb8c8 100644 --- a/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs +++ b/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs @@ -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; /// /// Built-in remnant placement strategy for the whole-job runner. Fills one candidate per trial with -/// a for the vertical or horizontal remnant policy, on a fresh -/// private plate, using the same run-scoped identity, progress, and accounting boundaries as -/// . +/// a for the vertical or horizontal remnant policy. Placement is +/// 's with that filler: the same fresh private plate per trial and +/// the same run-scoped identity, progress, and accounting boundaries. /// /// /// The remnant fillers share the Fill pipeline's automatic-rotation limitation, so non-automatic @@ -18,14 +16,12 @@ namespace OpenNest.Engine.Jobs.Placement; /// public sealed class RemnantPlateNester : IPlateNester { - private readonly Func fillerFactory; - private readonly OrderedPlateNester restrictedRotationNester = new(); - private readonly CandidatePlacementContext context = new(); + private readonly DefaultPlateNester nester; internal RemnantPlateNester(Func fillerFactory) { - this.fillerFactory = - fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)); + nester = new DefaultPlateNester( + fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory))); } /// Vertical-remnant policy: minimize X-extent, prefer horizontal placement. @@ -40,29 +36,5 @@ public sealed class RemnantPlateNester : IPlateNester PlatePlacementRequest request, IProgress 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); } diff --git a/OpenNest.IO.Tests/BreakoffPersistenceTests.cs b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs index 0d20515c..38ff77e0 100644 --- a/OpenNest.IO.Tests/BreakoffPersistenceTests.cs +++ b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs @@ -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 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(); - } } diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs index 3c2c88bc..ca9608f3 100644 --- a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs +++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs @@ -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 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; - } } diff --git a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs index 7e5fc611..5165d5de 100644 --- a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs +++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs @@ -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 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; - } } diff --git a/OpenNest.IO.Tests/CutPinPersistenceTests.cs b/OpenNest.IO.Tests/CutPinPersistenceTests.cs index fd953522..140587e8 100644 --- a/OpenNest.IO.Tests/CutPinPersistenceTests.cs +++ b/OpenNest.IO.Tests/CutPinPersistenceTests.cs @@ -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 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(); - } } diff --git a/OpenNest.IO.Tests/NestArchiveJson.cs b/OpenNest.IO.Tests/NestArchiveJson.cs new file mode 100644 index 00000000..3fcd5b97 --- /dev/null +++ b/OpenNest.IO.Tests/NestArchiveJson.cs @@ -0,0 +1,78 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; + +namespace OpenNest.IO.Tests; + +/// +/// Reads and rewrites the nest.json entry of a .nest archive, for tests that check or +/// corrupt the stored shape. Import with using static. +/// +internal static class NestArchiveJson +{ + public static (Nest Nest, IReadOnlyList 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(); + } + + /// A copy of the archive whose nest.json text passed through . + public static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func 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; + } +} diff --git a/OpenNest.IO.Tests/TabbedCutoutPersistenceTests.cs b/OpenNest.IO.Tests/TabbedCutoutPersistenceTests.cs index 6cb4ac46..c38538f8 100644 --- a/OpenNest.IO.Tests/TabbedCutoutPersistenceTests.cs +++ b/OpenNest.IO.Tests/TabbedCutoutPersistenceTests.cs @@ -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 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(); - } } diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index 12b0b8aa..c78e15ab 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -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 GetGeometry(string path) - { - try - { - var doc = ReadDocument(path); - return ConvertEntities(doc); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - return new List(); - } - } + public static List GetGeometry(string path) => + ReadGeometry(() => ReadDocument(path), null); - public static List 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(); - } - } + public static List GetGeometry(Stream stream) => + GetGeometry(stream, null); - public static List GetGeometry(string path, Func 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(); - } - } + public static List GetGeometry(string path, Func layerFilter) => + ReadGeometry(() => ReadDxf(new DxfReader(path)), layerFilter); - public static List GetGeometry(Stream stream, Func 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(); - } - } + public static List GetGeometry(Stream stream, Func 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 ReadGeometry(Func read, Func layerFilter) + { + try + { + return ConvertEntities(read(), layerFilter); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + return new List(); + } + } + + private static CadDocument ReadDxf(DxfReader reader) + { + using (reader) + return reader.Read(); + } + private static CadDocument ReadDocument(string path) { if (IsDwg(path)) diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index 9583cacd..6e10e2d6 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -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(); using var reader = command.ExecuteReader(); while (reader.Read()) records.Add(ReadDrawing(reader)); diff --git a/OpenNest.Server/NestDatabase.Sets.cs b/OpenNest.Server/NestDatabase.Sets.cs index b9ce54d5..e4a32a81 100644 --- a/OpenNest.Server/NestDatabase.Sets.cs +++ b/OpenNest.Server/NestDatabase.Sets.cs @@ -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 }; } } diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 299da577..d558db46 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -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(); 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) diff --git a/OpenNest.Server/NestQueryRequest.cs b/OpenNest.Server/NestQueryRequest.cs index ed01e87d..ef6569d8 100644 --- a/OpenNest.Server/NestQueryRequest.cs +++ b/OpenNest.Server/NestQueryRequest.cs @@ -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().Select(CamelCase))}."; + error = $"sort must be one of: {string.Join(", ", Enum.GetNames().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; - } } diff --git a/OpenNest/Controls/BrowsePager.cs b/OpenNest/Controls/BrowsePager.cs new file mode 100644 index 00000000..9206bbcd --- /dev/null +++ b/OpenNest/Controls/BrowsePager.cs @@ -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 +{ + /// + /// Drives a grid that pages through a : the summary + /// line, Previous/Next, sort glyphs and the failure text. The owner fills the rows + /// () and enables its own commands (). + /// + internal sealed class BrowsePager + where TPage : class, IBrowsePage + where TSort : struct, Enum + { + private const string LoadingText = "Loading..."; + + private readonly BrowseSession session; + private readonly DataGridView grid; + private readonly Label summary; + private readonly Button previous; + private readonly Button next; + private readonly string listName; + + /// What failed to load, as in "Could not load the drawing sets". + public BrowsePager(BrowseSession 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; + } + + /// Fills the grid from an applied page. + public Action Populate { get; init; } + + /// Runs after every request that leaves the owner open, to enable its commands. + public Action Settled { get; init; } + + /// Runs after a failed request has cleared the grid. + public Action Failed { get; init; } + + /// + /// 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. + /// + public async Task RunAsync(Func> 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; + } + + /// Marks the column whose is the session's sort field. + public void ShowSortGlyphs() + { + foreach (DataGridViewColumn column in grid.Columns) + { + column.HeaderCell.SortGlyphDirection = column.Tag is TSort field && EqualityComparer.Default.Equals(field, session.Sort) + ? session.Descending ? SortOrder.Descending : SortOrder.Ascending + : SortOrder.None; + } + } + + /// Every selected row's record in list order; the current row's alone when nothing is selected. + public IReadOnlyList SelectedRecords() + where TRecord : class + { + var picked = grid.SelectedRows.Cast() + .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; + } + } +} diff --git a/OpenNest/Controls/DrawingSetBrowser.cs b/OpenNest/Controls/DrawingSetBrowser.cs index f956a04a..27f602ba 100644 --- a/OpenNest/Controls/DrawingSetBrowser.cs +++ b/OpenNest/Controls/DrawingSetBrowser.cs @@ -18,8 +18,6 @@ namespace OpenNest.Controls /// 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 openNest; private Func isNestOpen = _ => false; private DrawingSetBrowseSession session; + private BrowsePager 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( + 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> 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> 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; diff --git a/OpenNest/Forms/AddReleaseDrawingsForm.cs b/OpenNest/Forms/AddReleaseDrawingsForm.cs index 7d3c9b7a..3600eb66 100644 --- a/OpenNest/Forms/AddReleaseDrawingsForm.cs +++ b/OpenNest/Forms/AddReleaseDrawingsForm.cs @@ -19,9 +19,8 @@ namespace OpenNest.Forms /// public partial class AddReleaseDrawingsForm : Form { - private const string LoadingText = "Loading..."; - private readonly DrawingLibraryBrowseSession librarySession; + private readonly BrowsePager libraryPager; private readonly Func, int, string> add; private readonly Func 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( + 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 /// The status line, for tests. internal string Status => statusLabel.Text; - // Every selected row in list order; the current row alone when nothing is selected. - private IReadOnlyList SelectedDrawings() - { - var picked = libraryGrid.SelectedRows.Cast() - .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 SelectedDrawings() => + libraryPager.SelectedRecords(); - private async Task RunLibraryAsync(Func> 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> 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; } } diff --git a/OpenNest/Forms/CutTimeMachineBox.cs b/OpenNest/Forms/CutTimeMachineBox.cs index 2fe22826..1973e602 100644 --- a/OpenNest/Forms/CutTimeMachineBox.cs +++ b/OpenNest/Forms/CutTimeMachineBox.cs @@ -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().First(c => c.Machine == saved); } - private static List Sorted(IEnumerable machines) => + private static List SortedByName(IEnumerable machines) => machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); /// The remembered machine, the first one until a choice is saved, or null for full speed. public static MachineConfig SavedMachine(IEnumerable machines) { - var sorted = Sorted(machines); + var sorted = SortedByName(machines); var saved = Properties.Settings.Default.CutTimeMachine; return saved == ConstantSpeed ? null diff --git a/OpenNest/Forms/DrawingLibraryForm.cs b/OpenNest/Forms/DrawingLibraryForm.cs index adc5b426..14e24e91 100644 --- a/OpenNest/Forms/DrawingLibraryForm.cs +++ b/OpenNest/Forms/DrawingLibraryForm.cs @@ -30,7 +30,6 @@ namespace OpenNest.Forms /// 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 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( + 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> 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> 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; diff --git a/OpenNest/Forms/EditDrawingSetForm.cs b/OpenNest/Forms/EditDrawingSetForm.cs index ccc84dda..54a8d71e 100644 --- a/OpenNest/Forms/EditDrawingSetForm.cs +++ b/OpenNest/Forms/EditDrawingSetForm.cs @@ -23,10 +23,9 @@ namespace OpenNest.Forms /// public partial class EditDrawingSetForm : Form { - private const string LoadingText = "Loading..."; - private readonly DrawingSetEditSession session; private readonly DrawingLibraryBrowseSession librarySession; + private readonly BrowsePager 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( + 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 SelectedLibraryDrawings() - { - var picked = libraryGrid.SelectedRows.Cast() - .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 SelectedLibraryDrawings() => + libraryPager.SelectedRecords(); - private async Task RunLibraryAsync(Func> 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> 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; diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index 8ac68877..13c55d9d 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -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 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( + 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 (). /// - private async Task RunAsync(Func> 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> 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];