From b9fc618a5043577c7206d6328acaf7c28b2155a2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 12 Nov 2026 16:47:51 -0500 Subject: [PATCH] feat(library): filter the drawing library by thickness, size, holes, date and customer Finding a part on the nest server meant scrolling 40k drawings: the library could only match an exact thickness or customer and knew nothing about a drawing's size or holes. The library browser gets a Filter dialog with inclusive ranges, and the query API takes them. The server never parses packages and stays off IO, so the server worker now measures each package beside its nest jobs (bounding sides, net area, closed cutouts inside the outline) and stores the summary keyed by package hash (schema v19). Identical geometry is measured once, an unreadable package is recorded once instead of retried forever, and size or hole limits match only measured drawings. Thickness ranges compare in inches across units and keep the library's rule that a drawing saved without a thickness suits any. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/DrawingGeometrySummary.cs | 108 +++++ OpenNest.Data/DrawingLibraryBrowseSession.cs | 18 +- OpenNest.Data/DrawingLibraryFilter.cs | 121 ++++++ OpenNest.Data/DrawingLibraryQuery.cs | 15 +- OpenNest.Data/DrawingLibraryRecord.cs | 6 + OpenNest.Data/DrawingSortField.cs | 6 + OpenNest.Data/RemoteDrawingGeometry.cs | 85 ++++ OpenNest.Data/RemoteDrawingLibrary.cs | 28 +- .../DrawingLibraryDatabaseTests.cs | 8 +- .../DrawingLibraryRangeFilterTests.cs | 256 ++++++++++++ .../DrawingSetDatabaseTests.cs | 4 +- OpenNest.Server.Tests/MaterialListTests.cs | 2 +- .../NestMaterialGradeTests.cs | 2 +- OpenNest.Server.Tests/NestStockCodesTests.cs | 2 +- OpenNest.Server.Tests/NestUnitsTests.cs | 2 +- .../SetReleaseDrawingTests.cs | 2 +- OpenNest.Server.Tests/SetReleaseTests.cs | 2 +- OpenNest.Server.Tests/SharedSettingsTests.cs | 2 +- .../DrawingGeometryBackfill.cs | 121 ++++++ OpenNest.Server.Worker/Program.cs | 9 +- OpenNest.Server.Worker/WorkerOptions.cs | 3 + OpenNest.Server/DrawingLibraryRequest.cs | 89 ++++ OpenNest.Server/DrawingLibraryRoutes.cs | 42 ++ .../NestDatabase.DrawingGeometry.cs | 188 +++++++++ OpenNest.Server/NestDatabase.Drawings.cs | 23 +- OpenNest.Server/NestDatabase.cs | 8 +- .../Data/DrawingGeometrySummaryTests.cs | 67 +++ .../Data/DrawingLibraryBrowseSessionTests.cs | 28 ++ .../Data/DrawingLibraryFilterTests.cs | 62 +++ .../Data/RemoteDrawingLibraryTests.cs | 31 ++ .../Forms/DrawingLibraryFilterFormTests.cs | 119 ++++++ .../Forms/DrawingLibraryFormTests.cs | 40 ++ .../Controls/DrawingLibraryView.Designer.cs | 49 ++- OpenNest/Controls/DrawingLibraryView.cs | 58 ++- .../DrawingLibraryFilterForm.Designer.cs | 385 ++++++++++++++++++ OpenNest/Forms/DrawingLibraryFilterForm.cs | 161 ++++++++ docs/nest-storage.md | 58 ++- docs/server-nesting.md | 3 + 38 files changed, 2170 insertions(+), 43 deletions(-) create mode 100644 OpenNest.Data/DrawingGeometrySummary.cs create mode 100644 OpenNest.Data/DrawingLibraryFilter.cs create mode 100644 OpenNest.Data/RemoteDrawingGeometry.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs create mode 100644 OpenNest.Server.Worker/DrawingGeometryBackfill.cs create mode 100644 OpenNest.Server/NestDatabase.DrawingGeometry.cs create mode 100644 OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs create mode 100644 OpenNest.Tests/Data/DrawingLibraryFilterTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs create mode 100644 OpenNest/Forms/DrawingLibraryFilterForm.Designer.cs create mode 100644 OpenNest/Forms/DrawingLibraryFilterForm.cs diff --git a/OpenNest.Data/DrawingGeometrySummary.cs b/OpenNest.Data/DrawingGeometrySummary.cs new file mode 100644 index 00000000..cd54be1e --- /dev/null +++ b/OpenNest.Data/DrawingGeometrySummary.cs @@ -0,0 +1,108 @@ +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Data; + +/// +/// The size and hole count of a library drawing's package, measured once per package by the +/// server worker (the server never parses packages) and used to filter the library. Lengths +/// are inches whatever units the drawing was drawn in. A package that could not be measured +/// keeps a summary with set so it is not measured again. +/// +public sealed record DrawingGeometrySummary +{ + /// Raised when the measurement changes, so the worker measures every package again. + public const int CurrentVersion = 1; + + public const int MaxErrorLength = 200; + + /// The that measured this package. + public int Version { get; init; } + + /// The longer side of the material's bounding box, in inches. + public double Length { get; init; } + + /// The shorter side of the material's bounding box, in inches. + public double Width { get; init; } + + /// Net material area (outline less holes), in square inches. + public double Area { get; init; } + + /// Closed contours inside the outline: holes and other cutouts. + public int Holes { get; init; } + + /// Why the package could not be measured; blank for a measured package. + public string Error { get; init; } = ""; + + public bool IsMeasured => Error.Length == 0; + + /// The first invalid field, or null when the summary may be stored. + public string? GetValidationError() + { + if (Version < 1) + return "version must be 1 or greater."; + if (Error is null) + return "error must be a string."; + if (Error.Length > MaxErrorLength) + return $"error must be at most {MaxErrorLength} characters."; + if (!double.IsFinite(Length) || !double.IsFinite(Width) || !double.IsFinite(Area)) + return "length, width and area must be finite."; + if (Length < 0 || Width < 0 || Area < 0 || Holes < 0) + return "length, width, area and holes must be zero or greater."; + if (Width > Length) + return "width must not exceed length."; + return null; + } + + /// Records why a package could not be measured. + public static DrawingGeometrySummary Failed(string error) + { + var text = string.IsNullOrWhiteSpace(error) ? "Could not be measured." : error.Trim(); + return new DrawingGeometrySummary + { + Version = CurrentVersion, + Error = text.Length > MaxErrorLength ? text[..MaxErrorLength] : text, + }; + } + + /// + /// Measures a drawing's material (marks such as etch and rapids excluded): the bounding box + /// of every closed contour, and the closed contours inside the largest one as holes. + /// + public static DrawingGeometrySummary Measure(Drawing drawing, Units units) + { + ArgumentNullException.ThrowIfNull(drawing); + if (drawing.Program is null) + return Failed("No program."); + + var material = ConvertProgram.ToGeometry(drawing.Program) + .Where(entity => SpecialLayers.IsMaterial(entity.Layer)); + var shapes = ShapeBuilder.GetShapes(material).Where(shape => shape.IsClosed()).ToList(); + if (shapes.Count == 0) + return Failed("No closed cut geometry."); + + var outline = shapes[0]; + foreach (var shape in shapes) + { + if (shape.BoundingBox.Area() > outline.BoundingBox.Area()) + outline = shape; + } + + var outer = outline.BoundingBox; + var holes = shapes.Where(shape => shape != outline && outer.Contains(shape.BoundingBox)).ToList(); + var bounds = shapes.Select(shape => shape.BoundingBox).ToList(); + var width = bounds.Max(b => b.Right) - bounds.Min(b => b.Left); + var height = bounds.Max(b => b.Top) - bounds.Min(b => b.Bottom); + var area = System.Math.Abs(outline.Area()) - holes.Sum(hole => System.Math.Abs(hole.Area())); + + var scale = units == Units.Millimeters ? 1 / 25.4 : 1.0; + return new DrawingGeometrySummary + { + Version = CurrentVersion, + Length = System.Math.Round(System.Math.Max(width, height) * scale, 4), + Width = System.Math.Round(System.Math.Min(width, height) * scale, 4), + Area = System.Math.Round(System.Math.Max(0, area) * scale * scale, 4), + Holes = holes.Count, + }; + } +} diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs index 8d89785c..219f7e34 100644 --- a/OpenNest.Data/DrawingLibraryBrowseSession.cs +++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs @@ -38,6 +38,9 @@ public sealed class DrawingLibraryBrowseSession : BrowseSessionTrue when the nest filter is lifted and every active drawing is listed. public bool ShowAll { get; private set; } + /// The Filter dialog's customer and ranges, applied with the nest filter; by default. + public DrawingLibraryFilter Filter { get; private set; } = DrawingLibraryFilter.None; + /// True when the latest request narrows by the nest's material, grade or thickness. public bool IsFilteredToNest => !ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null); @@ -45,7 +48,8 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession public override string Summary => PageSummary("drawings", - Search.Length > 0 ? "No drawings match the search." + !Filter.IsEmpty ? (Search.Length > 0 ? "No drawings match the search and filter." : "No drawings match the filter.") + : Search.Length > 0 ? "No drawings match the search." : IsFilteredToNest ? "No library drawings match this nest's material and thickness. Tick Show all materials to see every drawing." : "No drawings in the library."); @@ -56,6 +60,17 @@ public sealed class DrawingLibraryBrowseSession : BrowseSessionApplies the Filter dialog's choices and returns to the first page. + public Task SetFilterAsync(DrawingLibraryFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + var error = filter.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(filter)); + Filter = filter; + return FirstPageAsync(); + } + protected override Task QueryAsync(CancellationToken cancellationToken) => _library.QueryAsync( new DrawingLibraryQuery @@ -64,6 +79,7 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession +/// The drawing library's Filter dialog: a customer and inclusive ranges of thickness, size, +/// holes and saved date, all optional. Thickness and sizes are inches, whatever units each +/// drawing is in. Size and hole limits match only drawings the server has measured (see +/// ). The browse session sends it with every query. +/// +public sealed record DrawingLibraryFilter +{ + public static DrawingLibraryFilter None { get; } = new(); + + /// Exact customer, compared by . Blank means any. + public string Customer { get; init; } = ""; + + public double? MinThickness { get; init; } + + public double? MaxThickness { get; init; } + + /// + /// Whether drawings saved without a thickness (0, which suits any thickness) still match a + /// thickness range. False also leaves them out when no range is set. + /// + public bool IncludeBlankThickness { get; init; } = true; + + /// Limits on the longer side of the bounding box. + public double? MinLength { get; init; } + + public double? MaxLength { get; init; } + + /// Limits on the shorter side of the bounding box. + public double? MinWidth { get; init; } + + public double? MaxWidth { get; init; } + + public int? MinHoles { get; init; } + + public int? MaxHoles { get; init; } + + /// First and last day saved, inclusive, in the server's local dates. + public DateOnly? SavedFrom { get; init; } + + public DateOnly? SavedTo { get; init; } + + public bool IsEmpty => this == None; + + /// True when a size or hole limit is set, so only measured drawings can match. + public bool NeedsGeometry => + MinLength is not null || MaxLength is not null || MinWidth is not null || MaxWidth is not null + || MinHoles is not null || MaxHoles is not null; + + /// The first invalid range, or null when the filter may be sent. + public string? GetValidationError() => + RangeError("thickness", MinThickness, MaxThickness) + ?? RangeError("length", MinLength, MaxLength) + ?? RangeError("width", MinWidth, MaxWidth) + ?? RangeError("holes", MinHoles, MaxHoles) + ?? (SavedFrom is { } from && SavedTo is { } to && from > to ? "The saved-from date must not be after the saved-to date." : null) + ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength); + + /// A one-line summary for the library's filter label, for example "Thickness 0.0598-0.1875 in; Holes 2+". + public string Describe() + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(Customer)) + parts.Add($"Customer {Customer.Trim()}"); + if (Range(MinThickness, MaxThickness, " in") is { } thickness) + parts.Add($"Thickness {thickness}" + (IncludeBlankThickness ? " (or none)" : "")); + else if (!IncludeBlankThickness) + parts.Add("Has a thickness"); + if (Range(MinLength, MaxLength, " in") is { } length) + parts.Add($"Length {length}"); + if (Range(MinWidth, MaxWidth, " in") is { } width) + parts.Add($"Width {width}"); + if (Range(MinHoles, MaxHoles, "") is { } holes) + parts.Add($"Holes {holes}"); + if (SavedFrom is not null || SavedTo is not null) + parts.Add("Saved " + (SavedFrom, SavedTo) switch + { + ({ } from, { } to) => $"{Date(from)} to {Date(to)}", + ({ } from, null) => $"on or after {Date(from)}", + (null, { } to) => $"on or before {Date(to)}", + _ => "", + }); + return string.Join("; ", parts); + } + + internal static string? RangeError(string name, double? min, double? max) + { + if (min is { } low && (!double.IsFinite(low) || low < 0)) + return $"The lowest {name} must be a finite number, zero or greater."; + if (max is { } high && (!double.IsFinite(high) || high < 0)) + return $"The highest {name} must be a finite number, zero or greater."; + return min > max ? $"The lowest {name} must not be above the highest." : null; + } + + internal static string? RangeError(string name, int? min, int? max) + { + if (min < 0 || max < 0) + return $"The {name} limits must be zero or greater."; + return min > max ? $"The lowest {name} must not be above the highest." : null; + } + + private static string? Range(double? min, double? max, string unit) => (min, max) switch + { + ({ } low, { } high) when low == high => $"{Number(low)}{unit}", + ({ } low, { } high) => $"{Number(low)}-{Number(high)}{unit}", + ({ } low, null) => $"{Number(low)}{unit}+", + (null, { } high) => $"up to {Number(high)}{unit}", + _ => null, + }; + + private static string? Range(int? min, int? max, string unit) => + Range(min is { } low ? low : null, max is { } high ? high : (double?)null, unit); + + private static string Number(double value) => value.ToString("0.####", CultureInfo.CurrentCulture); + + private static string Date(DateOnly date) => date.ToString("d", CultureInfo.CurrentCulture); +} diff --git a/OpenNest.Data/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs index 6006fc29..a3b1aca8 100644 --- a/OpenNest.Data/DrawingLibraryQuery.cs +++ b/OpenNest.Data/DrawingLibraryQuery.cs @@ -37,6 +37,16 @@ public sealed class DrawingLibraryQuery public bool IncludeArchived { get; init; } + /// + /// Thickness, size, hole and saved-date ranges; + /// means none. Its customer applies only when is blank. + /// + public DrawingLibraryFilter Filter { get; init; } = DrawingLibraryFilter.None; + + /// The customer matched: , or the filter's when that is blank. + public string EffectiveCustomer => + string.IsNullOrWhiteSpace(Customer) ? (Filter?.Customer ?? "") : Customer; + /// Column to order by; ties are broken by id in the same direction. public DrawingSortField Sort { get; init; } = DrawingSortField.SavedAt; @@ -59,6 +69,8 @@ public sealed class DrawingLibraryQuery return "cutType must be Laser, Waterjet or Plasma."; if (Thickness is { } thickness && (!double.IsFinite(thickness) || thickness < 0)) return "thickness must be a finite number, zero or greater."; + if (Filter is null) + return "filter must not be null."; if (Offset < 0) return "offset must be zero or greater."; if (Limit < 1 || Limit > MaxLimit) @@ -66,6 +78,7 @@ public sealed class DrawingLibraryQuery return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength) ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength) ?? DrawingLibraryRules.TextError("materialName", MaterialName ?? "", DrawingLibraryRules.MaxTextLength) - ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength); + ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength) + ?? Filter.GetValidationError(); } } diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs index bd83bc5e..31fed42d 100644 --- a/OpenNest.Data/DrawingLibraryRecord.cs +++ b/OpenNest.Data/DrawingLibraryRecord.cs @@ -53,4 +53,10 @@ public sealed class DrawingLibraryRecord /// Who saved this revision, as reported by the client. public string SavedBy { get; set; } = ""; + + /// + /// The package's measured size and holes, returned by queries once the server worker has + /// measured it; null until then. Server-computed and ignored on upload. + /// + public DrawingGeometrySummary? Geometry { get; set; } } diff --git a/OpenNest.Data/DrawingSortField.cs b/OpenNest.Data/DrawingSortField.cs index c309827b..da8ef262 100644 --- a/OpenNest.Data/DrawingSortField.cs +++ b/OpenNest.Data/DrawingSortField.cs @@ -11,4 +11,10 @@ public enum DrawingSortField Thickness, CutType, Revision, + + /// The measured longer side; unmeasured drawings sort as smallest. + Length, + + /// The measured hole count; unmeasured drawings sort as fewest. + Holes, } diff --git a/OpenNest.Data/RemoteDrawingGeometry.cs b/OpenNest.Data/RemoteDrawingGeometry.cs new file mode 100644 index 00000000..f7b17cd7 --- /dev/null +++ b/OpenNest.Data/RemoteDrawingGeometry.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.RegularExpressions; + +namespace OpenNest.Data; + +/// +/// The server worker's side of library measurement (/api/drawing-packages): lists the +/// packages of current drawings not yet measured at a version, downloads one by its hash and +/// stores its . A server without these routes raises +/// ; other failures surface as . +/// +public sealed partial class RemoteDrawingGeometry : NestServerClient +{ + public const int MaxBatch = 500; + + public RemoteDrawingGeometry(string baseUrl) + : base(new HttpClient(), baseUrl, ownsClient: true) + { + } + + public RemoteDrawingGeometry(HttpClient httpClient, string baseUrl) + : base(httpClient, baseUrl, ownsClient: false) + { + } + + /// Up to package hashes with no summary at or later. + public async Task> GetUnmeasuredAsync(int version, int limit, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(limit, MaxBatch); + using var response = await _httpClient.GetAsync(Url( + $"api/drawing-packages/unmeasured?version={version.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), + cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(); + await NestServerHttp.EnsureSuccessAsync(response, "list unmeasured library drawings", cancellationToken); + var hashes = await response.Content.ReadFromJsonAsync>(NestServerHttp.JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty list of unmeasured drawings."); + if (hashes.Count > limit || hashes.Any(hash => hash is null || !IsSha256(hash))) + throw new IOException("Nest server returned an invalid list of unmeasured drawings."); + return hashes; + } + + /// The package stored under , or null when there is none. + public async Task GetPackageAsync(string sha256, CancellationToken cancellationToken = default) + { + ValidateSha(sha256); + using var response = await _httpClient.GetAsync(Url($"api/drawing-packages/{sha256}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await NestServerHttp.EnsureSuccessAsync(response, $"download library package {sha256}", cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + /// Stores a package's summary; false when the server no longer has the package. + public async Task PutAsync(string sha256, DrawingGeometrySummary summary, CancellationToken cancellationToken = default) + { + ValidateSha(sha256); + ArgumentNullException.ThrowIfNull(summary); + var error = summary.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(summary)); + using var response = await _httpClient.PutAsJsonAsync( + Url($"api/drawing-packages/{sha256}/geometry"), summary, NestServerHttp.JsonOptions, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + await NestServerHttp.EnsureSuccessAsync(response, $"store the size of library package {sha256}", cancellationToken); + return true; + } + + /// True for a lower-case hex SHA-256, the form the server stores. + public static bool IsSha256(string text) => Sha256Pattern().IsMatch(text); + + private static void ValidateSha(string sha256) + { + if (sha256 is null || !IsSha256(sha256)) + throw new ArgumentException("A package hash is 64 lower-case hex characters.", nameof(sha256)); + } + + [GeneratedRegex("^[0-9a-f]{64}$")] + private static partial Regex Sha256Pattern(); +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 830605e4..b166f238 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -246,7 +246,7 @@ public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStor .Append("&order=").Append(query.Descending ? "desc" : "asc") .Append("&offset=").Append(query.Offset.ToString(CultureInfo.InvariantCulture)) .Append("&limit=").Append(query.Limit.ToString(CultureInfo.InvariantCulture)); - AppendFilter(text, "customer", query.Customer); + AppendFilter(text, "customer", query.EffectiveCustomer); AppendFilter(text, "materialName", query.MaterialName); AppendFilter(text, "materialGrade", query.MaterialGrade); if (query.Thickness is { } thickness) @@ -257,9 +257,35 @@ public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStor text.Append("&cutFirst=").Append(cutFirst ? "true" : "false"); if (query.IncludeArchived) text.Append("&includeArchived=true"); + AppendRanges(text, query.Filter); return text.ToString(); } + // Only the limits that are set, so an unfiltered query stays readable by older servers. + private static void AppendRanges(StringBuilder text, DrawingLibraryFilter filter) + { + AppendNumber(text, "minThickness", filter.MinThickness); + AppendNumber(text, "maxThickness", filter.MaxThickness); + if (!filter.IncludeBlankThickness) + text.Append("&includeBlankThickness=false"); + AppendNumber(text, "minLength", filter.MinLength); + AppendNumber(text, "maxLength", filter.MaxLength); + AppendNumber(text, "minWidth", filter.MinWidth); + AppendNumber(text, "maxWidth", filter.MaxWidth); + AppendNumber(text, "minHoles", filter.MinHoles); + AppendNumber(text, "maxHoles", filter.MaxHoles); + if (filter.SavedFrom is { } from) + text.Append("&savedFrom=").Append(from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + if (filter.SavedTo is { } to) + text.Append("&savedTo=").Append(to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + private static void AppendNumber(StringBuilder text, string key, double? value) + { + if (value is { } number) + text.Append('&').Append(key).Append('=').Append(number.ToString("R", CultureInfo.InvariantCulture)); + } + private static void AppendFilter(StringBuilder text, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 01d1e96a..7619e706 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -23,7 +23,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -44,7 +44,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -81,7 +81,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=19"); + Execute(factory.DatabasePath, "PRAGMA user_version=20"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } @@ -106,7 +106,7 @@ public sealed class DrawingLibraryDatabaseTests """); using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(-3, upgraded.GetDrawing(record.Id)!.Settings.Priority); Assert.Equal(-3, Assert.Single(upgraded.DrawingHistory(record.Id, 0, 100)!.Items).Settings.Priority); Assert.Equal(int.MaxValue, upgraded.GetDrawing(other.Id)!.Settings.Priority); diff --git a/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs b/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs new file mode 100644 index 00000000..93fc2dd5 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs @@ -0,0 +1,256 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging.Abstractions; +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Server.Worker; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Server.Tests; + +/// +/// The library's Filter dialog ranges: thickness in inches across units, measured size and +/// holes from the worker's package summaries, saved dates, and the measurement routes. +/// +public sealed class DrawingLibraryRangeFilterTests +{ + private static DrawingGeometrySummary Measured(double length, double width, int holes) => new() + { + Version = DrawingGeometrySummary.CurrentVersion, + Length = length, + Width = width, + Area = length * width, + Holes = holes, + }; + + // Name, thickness, units, and the summary stored for its package (null: not yet measured). + private static async Task Seed(HttpClient client) + { + var rows = new (string Name, double Thickness, Units Units, DrawingGeometrySummary? Geometry)[] + { + ("Gauge washer", 0.0598, Units.Inches, Measured(2, 2, 2)), + ("Plate tab", 0.1875, Units.Inches, Measured(3.5, 1, 3)), + ("Thick tab", 0.25, Units.Inches, Measured(3, 1, 2)), + ("Big gauge panel", 0.06, Units.Inches, Measured(12, 4, 8)), + ("Metric clip", 3, Units.Millimeters, Measured(3.9, 0.5, 2)), + ("No thickness", 0, Units.Inches, Measured(1, 1, 4)), + ("Unmeasured", 0.105, Units.Inches, null), + ("Unreadable", 0.105, Units.Inches, DrawingGeometrySummary.Failed("Synthetic failure")), + }; + foreach (var row in rows) + { + var record = Record(row.Name); + record.Thickness = row.Thickness; + record.Units = row.Units; + var package = Package(row.Name); + await Upload(client, record, package); + if (row.Geometry is not null) + { + using var put = await client.PutAsJsonAsync($"/api/drawing-packages/{Sha256(package)}/geometry", row.Geometry, Options); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + } + } + } + + [Theory] + [InlineData("minThickness=0.0598&maxThickness=0.1875", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Unmeasured|Unreadable")] + [InlineData("minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false", "Big gauge panel|Gauge washer|Metric clip|Plate tab|Unmeasured|Unreadable")] + [InlineData("includeBlankThickness=false&maxThickness=0.1", "Big gauge panel|Gauge washer")] + [InlineData("minThickness=0.2&includeBlankThickness=false", "Thick tab")] + [InlineData("maxLength=4", "Gauge washer|Metric clip|No thickness|Plate tab|Thick tab")] + [InlineData("maxLength=4&minHoles=3", "No thickness|Plate tab")] + [InlineData("minWidth=1&maxWidth=2", "Gauge washer|No thickness|Plate tab|Thick tab")] + [InlineData("minHoles=0", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Thick tab")] + [InlineData("maxHoles=2&minLength=3", "Metric clip|Thick tab")] + [InlineData("minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false&maxLength=4&minHoles=2", "Gauge washer|Metric clip|Plate tab")] + [InlineData("savedFrom=2000-01-01", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Thick tab|Unmeasured|Unreadable")] + [InlineData("savedTo=2000-01-01", "")] + public async Task Ranges_MatchInclusivelyInInchesAndOnlyMeasuredSizes(string query, string expected) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Seed(client); + + var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc"); + + Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name))); + Assert.Equal(page.Items.Count, page.Total); + } + + [Fact] + public async Task Query_ReturnsEachDrawingsSummary_AndSortsByLengthAndHoles() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Seed(client); + + var page = await DrawingLibraryApiTests.Query(client, "sort=length&order=desc"); + Assert.Equal("Big gauge panel", page.Items[0].Name); + Assert.Equal(new DrawingGeometrySummary { Version = 1, Length = 12, Width = 4, Area = 48, Holes = 8 }, page.Items[0].Geometry); + Assert.Null(page.Items.Single(r => r.Name == "Unmeasured").Geometry); + Assert.Equal("Synthetic failure", page.Items.Single(r => r.Name == "Unreadable").Geometry!.Error); + + var byHoles = await DrawingLibraryApiTests.Query(client, "sort=holes&order=desc&limit=1"); + Assert.Equal("Big gauge panel", Assert.Single(byHoles.Items).Name); + } + + [Fact] + public async Task SavedDates_CompareTheServersLocalDate() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Upload(client, Record("Today"), Package("today")); + var today = DateTime.Now.ToString("yyyy-MM-dd"); + var tomorrow = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd"); + + Assert.Equal(1, (await DrawingLibraryApiTests.Query(client, $"savedFrom={today}&savedTo={today}")).Total); + Assert.Equal(0, (await DrawingLibraryApiTests.Query(client, $"savedFrom={tomorrow}")).Total); + } + + [Theory] + [InlineData("minThickness=-1")] + [InlineData("maxLength=abc")] + [InlineData("minLength=5&maxLength=4")] + [InlineData("minHoles=-2")] + [InlineData("minHoles=1.5")] + [InlineData("savedFrom=11/12/2026")] + [InlineData("savedFrom=2026-02-01&savedTo=2026-01-01")] + [InlineData("includeBlankThickness=maybe")] + [InlineData("maxLength=NaN")] + public async Task InvalidRanges_AreRefused(string query) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/drawings/query?" + query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Unmeasured_ListsEachCurrentPackageOnceUntilMeasuredAtTheVersion() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var shared = Package("shared"); + await Upload(client, Record("First"), shared); + await Upload(client, Record("Same geometry"), shared); + var other = Package("other"); + await Upload(client, Record("Other"), other); + var expected = new[] { Sha256(shared), Sha256(other) }.Order().ToList(); + + Assert.Equal(expected, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=1&limit=10")); + + using (var put = await client.PutAsJsonAsync($"/api/drawing-packages/{Sha256(shared)}/geometry", Measured(2, 1, 0), Options)) + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + Assert.Equal(new[] { Sha256(other) }, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=1&limit=10")); + // A newer measurement version lists it again. + Assert.Equal(expected, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=2&limit=10")); + Assert.Equal(shared, await client.GetByteArrayAsync($"/api/drawing-packages/{Sha256(shared)}")); + } + + [Fact] + public async Task OlderVersionSummary_NeverReplacesANewerOne() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = Package("versioned"); + await Upload(client, Record("Versioned"), package); + var url = $"/api/drawing-packages/{Sha256(package)}/geometry"; + + (await client.PutAsJsonAsync(url, Measured(5, 5, 5) with { Version = 2 }, Options)).EnsureSuccessStatusCode(); + (await client.PutAsJsonAsync(url, Measured(1, 1, 1), Options)).EnsureSuccessStatusCode(); + + var page = await DrawingLibraryApiTests.Query(client, ""); + Assert.Equal(5, Assert.Single(page.Items).Geometry!.Holes); + } + + [Theory] + [InlineData("{\"version\":0,\"length\":1,\"width\":1,\"area\":1,\"holes\":0}")] + [InlineData("{\"version\":1,\"length\":1,\"width\":2,\"area\":1,\"holes\":0}")] + [InlineData("{\"version\":1,\"length\":1,\"width\":1,\"area\":1,\"holes\":-1}")] + [InlineData("not json")] + public async Task InvalidSummary_IsRefused(string json) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = Package("invalid"); + await Upload(client, Record("Invalid"), package); + + using var response = await client.PutAsync($"/api/drawing-packages/{Sha256(package)}/geometry", + new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task UnknownOrMalformedHash_IsNotFoundOrRefused() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var unknown = await client.PutAsJsonAsync($"/api/drawing-packages/{new string('a', 64)}/geometry", Measured(1, 1, 0), Options); + using var malformed = await client.GetAsync("/api/drawing-packages/ABC"); + using var badLimit = await client.GetAsync("/api/drawing-packages/unmeasured?version=1&limit=501"); + + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, malformed.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, badLimit.StatusCode); + } + + private static Drawing Washer(string name, double size, int holes) + { + var program = new CncProgram(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(0, size))); + program.Codes.Add(new LinearMove(new Vector(size, size))); + program.Codes.Add(new LinearMove(new Vector(size, 0))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + for (var i = 0; i < holes; i++) + { + var x = 0.25 + i * 0.75; + program.Codes.Add(new RapidMove(new Vector(x, 0.25))); + program.Codes.Add(new LinearMove(new Vector(x + 0.5, 0.25))); + program.Codes.Add(new LinearMove(new Vector(x + 0.5, 0.75))); + program.Codes.Add(new LinearMove(new Vector(x, 0.75))); + program.Codes.Add(new LinearMove(new Vector(x, 0.25))); + } + return new Drawing(name, program); + } + + [Fact] + public async Task WorkerBackfill_MeasuresRealPackages_SoTheSizeFilterFindsThem() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var url = client.BaseAddress!.ToString(); + using var library = new RemoteDrawingLibrary(client, url); + using var geometry = new RemoteDrawingGeometry(client, url); + foreach (var (name, size, holes) in new[] { ("Small two-hole", 3.0, 2), ("Small one-hole", 3.0, 1), ("Large two-hole", 6.0, 2) }) + { + var record = Record(name); + record.Thickness = 0.1046; + await library.UploadAsync(record, DrawingPackage.Write(Washer(name, size, holes), Units.Inches)); + } + var badPackage = Package("not a drawing"); + await Upload(client, Record("Not a drawing"), badPackage); + var backfill = new DrawingGeometryBackfill(geometry, NullLogger.Instance); + + Assert.Equal(4, await backfill.RunOnceAsync(CancellationToken.None)); + Assert.Equal(0, await backfill.RunOnceAsync(CancellationToken.None)); + + var page = await library.QueryAsync(new DrawingLibraryQuery + { + Filter = new DrawingLibraryFilter { MinThickness = 0.0598, MaxThickness = 0.1875, MaxLength = 4, MinHoles = 2 }, + }); + var found = Assert.Single(page.Items); + Assert.Equal("Small two-hole", found.Name); + Assert.Equal((3, 3, 2), (found.Geometry!.Length, found.Geometry.Width, found.Geometry.Holes)); + Assert.Equal(9 - 2 * 0.25, found.Geometry.Area, 6); + var failed = (await DrawingLibraryApiTests.Query(client, "search=Not%20a%20drawing")).Items.Single(); + Assert.False(failed.Geometry!.IsMeasured); + } +} diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 19be8a90..c36cd79c 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -23,7 +23,7 @@ public sealed class DrawingSetDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); Assert.Equal(before, Digest(factory.DatabasePath)); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -48,7 +48,7 @@ public sealed class DrawingSetDatabaseTests Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index 0623e578..5177668b 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -25,7 +25,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListMaterials()); Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade)); Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName); diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index 99fa4f6a..d8f614c1 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -75,7 +75,7 @@ public sealed class NestMaterialGradeTests Assert.Equal("Stainless", record.Material); Assert.Equal("", record.MaterialGrade); Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/NestStockCodesTests.cs b/OpenNest.Server.Tests/NestStockCodesTests.cs index 2897da11..087ab69a 100644 --- a/OpenNest.Server.Tests/NestStockCodesTests.cs +++ b/OpenNest.Server.Tests/NestStockCodesTests.cs @@ -119,7 +119,7 @@ public sealed class NestStockCodesTests database.SetDeleted(deleted, false, record.Revision); Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode)); } - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static Nest Nest(string name, NestStatus status, params string[][] plateCodes) diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs index bddfc562..afef63b7 100644 --- a/OpenNest.Server.Tests/NestUnitsTests.cs +++ b/OpenNest.Server.Tests/NestUnitsTests.cs @@ -77,7 +77,7 @@ public sealed class NestUnitsTests Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units)); Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units); Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, Units units) => new() diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs index abc32ada..930186b1 100644 --- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -152,7 +152,7 @@ public sealed class SetReleaseDrawingTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); var kept = upgraded.GetRelease(releaseId)!; Assert.Empty(kept.Drawings); Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity)); diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index b2bdc7c9..58ca473c 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -295,7 +295,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'")); var release = upgraded.GetRelease(releaseId)!; Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone)); diff --git a/OpenNest.Server.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs index a032da67..3d3a897a 100644 --- a/OpenNest.Server.Tests/SharedSettingsTests.cs +++ b/OpenNest.Server.Tests/SharedSettingsTests.cs @@ -101,7 +101,7 @@ public sealed class SharedSettingsTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListSharedSettings()); } diff --git a/OpenNest.Server.Worker/DrawingGeometryBackfill.cs b/OpenNest.Server.Worker/DrawingGeometryBackfill.cs new file mode 100644 index 00000000..dd473cae --- /dev/null +++ b/OpenNest.Server.Worker/DrawingGeometryBackfill.cs @@ -0,0 +1,121 @@ +using OpenNest.Data; +using OpenNest.IO; + +namespace OpenNest.Server.Worker; + +/// +/// Measures library drawing packages the server lists as unmeasured and stores each +/// , so the library can be filtered by size and holes. +/// A package that cannot be read or measured is stored as failed and not tried again; a +/// server or network failure stores nothing, so the package is tried on a later round. +/// +public sealed class DrawingGeometryBackfill +{ + public const int BatchSize = 100; + + // A pathological drawing must not stall the rest; its worker thread is abandoned. + private static readonly TimeSpan MeasureTimeout = TimeSpan.FromSeconds(30); + + private readonly RemoteDrawingGeometry _server; + private readonly ILogger _logger; + + public DrawingGeometryBackfill(RemoteDrawingGeometry server, ILogger logger) + { + _server = server; + _logger = logger; + } + + /// Measures one batch; returns how many summaries were stored (0 when none were waiting). + public async Task RunOnceAsync(CancellationToken cancellationToken) + { + var hashes = await _server.GetUnmeasuredAsync(DrawingGeometrySummary.CurrentVersion, BatchSize, cancellationToken); + var stored = 0; + foreach (var sha in hashes) + { + cancellationToken.ThrowIfCancellationRequested(); + var package = await _server.GetPackageAsync(sha, cancellationToken); + if (package is null) + continue; + var summary = await MeasureAsync(package, cancellationToken); + if (!summary.IsMeasured) + _logger.LogInformation("Library package {Sha} could not be measured: {Error}", sha, summary.Error); + if (await _server.PutAsync(sha, summary, cancellationToken)) + stored++; + } + return stored; + } + + /// Reads and measures one package; a failure becomes a failed summary. + public static async Task MeasureAsync(byte[] package, CancellationToken cancellationToken) + { + try + { + return await Task.Run(() => + { + var contents = DrawingPackage.Read(package); + return DrawingGeometrySummary.Measure(contents.Drawing, contents.Units); + }, cancellationToken).WaitAsync(MeasureTimeout, cancellationToken); + } + catch (TimeoutException) + { + return DrawingGeometrySummary.Failed($"Measuring took over {MeasureTimeout.TotalSeconds:0} s."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return DrawingGeometrySummary.Failed(ex.Message); + } + } +} + +/// +/// Runs beside the nest job worker: batch after batch +/// while packages are waiting, then every . +/// +public sealed class DrawingGeometryBackfillService : BackgroundService +{ + private readonly DrawingGeometryBackfill _backfill; + private readonly WorkerOptions _options; + private readonly ILogger _logger; + + public DrawingGeometryBackfillService(DrawingGeometryBackfill backfill, WorkerOptions options, + ILogger logger) + { + _backfill = backfill; + _options = options; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var total = 0; + while (!stoppingToken.IsCancellationRequested) + { + var stored = 0; + try + { + stored = await _backfill.RunOnceAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (ServerTooOldException) + { + _logger.LogWarning("The nest server does not measure library drawings; update it to filter the library by size."); + } + catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException) + { + _logger.LogWarning("Nest server unavailable for library measurement: {Message}", ex.Message); + } + + if (stored > 0) + { + total += stored; + _logger.LogInformation("Measured {Count} library drawing package(s) so far.", total); + continue; + } + try { await Task.Delay(_options.GeometryPollInterval, stoppingToken); } + catch (OperationCanceledException) { return; } + } + } +} diff --git a/OpenNest.Server.Worker/Program.cs b/OpenNest.Server.Worker/Program.cs index 82467ddb..f2706d73 100644 --- a/OpenNest.Server.Worker/Program.cs +++ b/OpenNest.Server.Worker/Program.cs @@ -3,8 +3,9 @@ using OpenNest.Data; using OpenNest.Server.Worker; // The nest server's worker: takes queued whole-job nests from OPENNEST_SERVER_URL and solves -// them one at a time. Run it in its own container with CPU and memory limits (compose.server.yaml) -// so nesting never slows nest open and save. See docs/server-nesting.md. +// them one at a time, and measures library drawings for the library's size filters. Run it in +// its own container with CPU and memory limits (compose.server.yaml) so nesting never slows +// nest open and save. See docs/server-nesting.md. var options = WorkerOptions.FromEnvironment(Environment.GetEnvironmentVariable); // Below the server and anything else on the host when they share CPUs; the container's @@ -18,6 +19,10 @@ builder.Services.AddSingleton(_ => new RemoteNestJobs(options.ServerUrl)); builder.Services.AddSingleton(sp => new NestJobWorker( sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddHostedService(); +// Measures library drawings for the size and hole filters; see DrawingGeometryBackfill. +builder.Services.AddSingleton(_ => new RemoteDrawingGeometry(options.ServerUrl)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); // A running job is given back, not abandoned, when the container stops. builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20)); builder.Build().Run(); diff --git a/OpenNest.Server.Worker/WorkerOptions.cs b/OpenNest.Server.Worker/WorkerOptions.cs index a4bde964..cdbbe90b 100644 --- a/OpenNest.Server.Worker/WorkerOptions.cs +++ b/OpenNest.Server.Worker/WorkerOptions.cs @@ -13,6 +13,9 @@ public sealed class WorkerOptions /// How long to wait before asking again when no job was queued. public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5); + /// How long library measurement waits before asking again when nothing was waiting. + public TimeSpan GeometryPollInterval { get; init; } = TimeSpan.FromSeconds(60); + /// Touched every round; the container health check fails when it goes stale. public string AliveFile { get; init; } = Path.Combine(Path.GetTempPath(), "opennest-worker.alive"); diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs index 559b8fdb..d53ff7e6 100644 --- a/OpenNest.Server/DrawingLibraryRequest.cs +++ b/OpenNest.Server/DrawingLibraryRequest.cs @@ -16,6 +16,8 @@ internal static class DrawingLibraryRequest { "search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst", "includeArchived", "sort", "order", "offset", "limit", + "minThickness", "maxThickness", "includeBlankThickness", "minLength", "maxLength", "minWidth", "maxWidth", + "minHoles", "maxHoles", "savedFrom", "savedTo", }; // Integer enum values are refused; names are additionally checked before deserializing. @@ -113,6 +115,9 @@ internal static class DrawingLibraryRequest if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) return false; + if (!TryReadFilter(values, out var filter, out error)) + return false; + query = new DrawingLibraryQuery { Search = values["search"].ToString(), @@ -127,11 +132,95 @@ internal static class DrawingLibraryRequest Descending = descending, Offset = offset, Limit = limit, + Filter = filter, }; error = query.GetValidationError() ?? ""; return error.Length == 0; } + // Thickness and sizes are inches; holes whole numbers; dates yyyy-MM-dd. The customer stays on the query. + private static bool TryReadFilter(IQueryCollection values, out DrawingLibraryFilter filter, out string error) + { + filter = DrawingLibraryFilter.None; + double? minThickness = null, maxThickness = null, minLength = null, maxLength = null, minWidth = null, maxWidth = null; + int? minHoles = null, maxHoles = null; + DateOnly? savedFrom = null, savedTo = null; + var includeBlank = true; + if (!TryNumber(values, "minThickness", ref minThickness, out error) || !TryNumber(values, "maxThickness", ref maxThickness, out error) + || !TryNumber(values, "minLength", ref minLength, out error) || !TryNumber(values, "maxLength", ref maxLength, out error) + || !TryNumber(values, "minWidth", ref minWidth, out error) || !TryNumber(values, "maxWidth", ref maxWidth, out error) + || !TryCount(values, "minHoles", ref minHoles, out error) || !TryCount(values, "maxHoles", ref maxHoles, out error) + || !TryDate(values, "savedFrom", ref savedFrom, out error) || !TryDate(values, "savedTo", ref savedTo, out error)) + return false; + + if (values.TryGetValue("includeBlankThickness", out var blankText) && !bool.TryParse(blankText.ToString(), out includeBlank)) + { + error = "includeBlankThickness must be true or false."; + return false; + } + + filter = new DrawingLibraryFilter + { + MinThickness = minThickness, + MaxThickness = maxThickness, + IncludeBlankThickness = includeBlank, + MinLength = minLength, + MaxLength = maxLength, + MinWidth = minWidth, + MaxWidth = maxWidth, + MinHoles = minHoles, + MaxHoles = maxHoles, + SavedFrom = savedFrom, + SavedTo = savedTo, + }; + return true; + } + + private static bool TryNumber(IQueryCollection values, string key, ref double? value, out string error) + { + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + if (double.TryParse(text.ToString(), NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var parsed) + && double.IsFinite(parsed)) + { + value = parsed; + return true; + } + + error = $"{key} must be a finite number, zero or greater."; + return false; + } + + private static bool TryCount(IQueryCollection values, string key, ref int? value, out string error) + { + var parsed = 0; + if (!values.ContainsKey(key)) + { + error = ""; + return true; + } + if (!TryInteger(values, key, ref parsed, out error)) + return false; + value = parsed; + return true; + } + + private static bool TryDate(IQueryCollection values, string key, ref DateOnly? value, out string error) + { + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + if (DateOnly.TryParseExact(text.ToString(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + value = parsed; + return true; + } + + error = $"{key} must be a date written yyyy-MM-dd."; + return false; + } + /// Reads and validates one metadata JSON object. public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error) { diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs index b1e547fc..09c47f5a 100644 --- a/OpenNest.Server/DrawingLibraryRoutes.cs +++ b/OpenNest.Server/DrawingLibraryRoutes.cs @@ -20,6 +20,48 @@ internal static class DrawingLibraryRoutes ? Results.Ok(db.QueryDrawings(query)) : Results.BadRequest(error)); + // Measurement for the worker: the server never parses packages (see NestDatabase.DrawingGeometry). + app.MapGet("/api/drawing-packages/unmeasured", (HttpRequest request, NestDatabase db) => + { + var version = 0; + var limit = 100; + foreach (var key in request.Query.Keys) + { + if (key is not ("version" or "limit") || request.Query[key].Count > 1) + return Results.BadRequest($"Unknown or repeated parameter '{key}'."); + } + if (!DrawingLibraryRequest.TryInteger(request.Query, "version", ref version, out var error) + || !DrawingLibraryRequest.TryInteger(request.Query, "limit", ref limit, out error)) + return Results.BadRequest(error); + if (version < 1 || limit < 1 || limit > RemoteDrawingGeometry.MaxBatch) + return Results.BadRequest($"version must be 1 or greater and limit 1 to {RemoteDrawingGeometry.MaxBatch}."); + return Results.Ok(db.GetUnmeasuredPackages(version, limit)); + }); + + app.MapGet("/api/drawing-packages/{sha}", (string sha, NestDatabase db) => + !RemoteDrawingGeometry.IsSha256(sha) ? Results.BadRequest("Invalid package hash.") : + db.GetPackageBySha(sha) is { } package ? Results.File(package, "application/zip", $"{sha}.nest") : Results.NotFound()); + + app.MapPut("/api/drawing-packages/{sha}/geometry", async (string sha, HttpRequest request, NestDatabase db) => + { + if (!RemoteDrawingGeometry.IsSha256(sha)) + return Results.BadRequest("Invalid package hash."); + DrawingGeometrySummary? summary; + try + { + summary = await request.ReadFromJsonAsync(request.HttpContext.RequestAborted); + } + catch (System.Text.Json.JsonException) + { + return Results.BadRequest("Invalid geometry JSON."); + } + if (summary is null) + return Results.BadRequest("Missing geometry."); + if (summary.GetValidationError() is { } invalid) + return Results.BadRequest(invalid); + return db.SetPackageGeometry(sha, summary) ? Results.NoContent() : Results.NotFound(); + }); + app.MapGet("/api/drawings/{id:guid}", (Guid id, NestDatabase db) => db.GetDrawing(id) is { } record ? Results.Ok(record) : Results.NotFound()); diff --git a/OpenNest.Server/NestDatabase.DrawingGeometry.cs b/OpenNest.Server/NestDatabase.DrawingGeometry.cs new file mode 100644 index 00000000..dfd3377a --- /dev/null +++ b/OpenNest.Server/NestDatabase.DrawingGeometry.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Measured library package sizes. The server never parses packages, so the worker lists the +/// packages of current drawings without a summary at its version, measures each and stores it +/// here keyed by package hash: identical geometry is measured once, and a metadata-only +/// revision keeps its summary. Queries join it to filter and sort by size and holes. +/// +public sealed partial class NestDatabase +{ + // v19: one summary per package; error is nonblank (and the sizes 0) when it could not be measured. + private const string DrawingGeometrySchema = """ + CREATE TABLE IF NOT EXISTS drawing_package_geometry ( + sha256 TEXT NOT NULL PRIMARY KEY REFERENCES drawing_packages(sha256), + version INTEGER NOT NULL, + length REAL NOT NULL, + width REAL NOT NULL, + area REAL NOT NULL, + holes INTEGER NOT NULL, + error TEXT NOT NULL DEFAULT '', + measuredAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_drawing_package_geometry_length ON drawing_package_geometry(length); + PRAGMA user_version=19; + """; + + // Lengths and thickness compare in inches; the slack absorbs rounding and millimeter conversion. + private const double RangeSlack = 1e-6; + + private const string GeometryJoin = "LEFT JOIN drawing_package_geometry g ON g.sha256 = drawings.packageSha256"; + + private const string GeometryColumns = "g.version, g.length, g.width, g.area, g.holes, g.error"; + + private const string ThicknessInches = "(CASE WHEN units = 'Millimeters' THEN thickness / 25.4 ELSE thickness END)"; + + /// + /// Up to distinct package hashes of current drawings (archived ones + /// included) with no summary at or later, in hash order. + /// + public IReadOnlyList GetUnmeasuredPackages(int version, int limit) + { + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1); + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = """ + SELECT DISTINCT d.packageSha256 FROM drawings d + LEFT JOIN drawing_package_geometry g ON g.sha256 = d.packageSha256 + WHERE g.sha256 IS NULL OR g.version < $version + ORDER BY d.packageSha256 LIMIT $limit + """; + command.Parameters.AddWithValue("$version", version); + command.Parameters.AddWithValue("$limit", limit); + var hashes = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + hashes.Add(reader.GetString(0)); + return hashes; + } + } + + /// The package stored under a hash, or null. + public byte[]? GetPackageBySha(string sha256) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT package FROM drawing_packages WHERE sha256=$sha"; + command.Parameters.AddWithValue("$sha", sha256); + return command.ExecuteScalar() as byte[]; + } + } + + /// + /// Stores a package's summary; false when the package is unknown. A summary from an older + /// version than the stored one is ignored, so an outdated worker cannot undo a newer one. + /// + public bool SetPackageGeometry(string sha256, DrawingGeometrySummary summary) + { + ArgumentNullException.ThrowIfNull(summary); + var error = summary.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(summary)); + + lock (_sync) + { + using var exists = _connection.CreateCommand(); + exists.CommandText = "SELECT COUNT(*) FROM drawing_packages WHERE sha256=$sha"; + exists.Parameters.AddWithValue("$sha", sha256); + if ((long)exists.ExecuteScalar()! == 0) + return false; + + using var command = _connection.CreateCommand(); + command.CommandText = """ + INSERT INTO drawing_package_geometry (sha256, version, length, width, area, holes, error, measuredAt) + VALUES ($sha, $version, $length, $width, $area, $holes, $error, $measuredAt) + ON CONFLICT(sha256) DO UPDATE SET + version = excluded.version, length = excluded.length, width = excluded.width, + area = excluded.area, holes = excluded.holes, error = excluded.error, measuredAt = excluded.measuredAt + WHERE excluded.version >= drawing_package_geometry.version + """; + var measured = summary.IsMeasured; + command.Parameters.AddWithValue("$sha", sha256); + command.Parameters.AddWithValue("$version", summary.Version); + command.Parameters.AddWithValue("$length", measured ? summary.Length : 0); + command.Parameters.AddWithValue("$width", measured ? summary.Width : 0); + command.Parameters.AddWithValue("$area", measured ? summary.Area : 0); + command.Parameters.AddWithValue("$holes", measured ? summary.Holes : 0); + command.Parameters.AddWithValue("$error", summary.Error); + command.Parameters.AddWithValue("$measuredAt", DateTime.Now.ToString("o", CultureInfo.InvariantCulture)); + command.ExecuteNonQuery(); + return true; + } + } + + // The filter's ranges as WHERE conditions over drawings joined to their geometry (GeometryJoin). + private static void AddRangeConditions(List conditions, DrawingLibraryFilter filter) + { + if (filter.MinThickness is not null || filter.MaxThickness is not null) + { + var range = new List { "thickness > 0" }; + if (filter.MinThickness is not null) range.Add($"{ThicknessInches} >= $minThickness"); + if (filter.MaxThickness is not null) range.Add($"{ThicknessInches} <= $maxThickness"); + var inRange = "(" + string.Join(" AND ", range) + ")"; + // A drawing saved without a thickness suits any, as with the exact thickness filter. + conditions.Add(filter.IncludeBlankThickness ? $"({inRange} OR thickness = 0)" : inRange); + } + else if (!filter.IncludeBlankThickness) + { + conditions.Add("thickness > 0"); + } + + if (filter.NeedsGeometry) + conditions.Add("g.error = ''"); + if (filter.MinLength is not null) conditions.Add("g.length >= $minLength"); + if (filter.MaxLength is not null) conditions.Add("g.length <= $maxLength"); + if (filter.MinWidth is not null) conditions.Add("g.width >= $minWidth"); + if (filter.MaxWidth is not null) conditions.Add("g.width <= $maxWidth"); + if (filter.MinHoles is not null) conditions.Add("g.holes >= $minHoles"); + if (filter.MaxHoles is not null) conditions.Add("g.holes <= $maxHoles"); + // savedAt is the server's local ISO time, so its first ten characters are its local date. + if (filter.SavedFrom is not null) conditions.Add("substr(savedAt, 1, 10) >= $savedFrom"); + if (filter.SavedTo is not null) conditions.Add("substr(savedAt, 1, 10) <= $savedTo"); + } + + private static void AddRangeParameters(SqliteCommand command, DrawingLibraryFilter filter) + { + AddLower(command, "$minThickness", filter.MinThickness); + AddUpper(command, "$maxThickness", filter.MaxThickness); + AddLower(command, "$minLength", filter.MinLength); + AddUpper(command, "$maxLength", filter.MaxLength); + AddLower(command, "$minWidth", filter.MinWidth); + AddUpper(command, "$maxWidth", filter.MaxWidth); + if (filter.MinHoles is { } minHoles) command.Parameters.AddWithValue("$minHoles", minHoles); + if (filter.MaxHoles is { } maxHoles) command.Parameters.AddWithValue("$maxHoles", maxHoles); + if (filter.SavedFrom is { } from) command.Parameters.AddWithValue("$savedFrom", from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + if (filter.SavedTo is { } to) command.Parameters.AddWithValue("$savedTo", to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + private static void AddLower(SqliteCommand command, string name, double? value) + { + if (value is { } number) + command.Parameters.AddWithValue(name, number - RangeSlack); + } + + private static void AddUpper(SqliteCommand command, string name, double? value) + { + if (value is { } number) + command.Parameters.AddWithValue(name, number + RangeSlack); + } + + // Reads GeometryColumns from column `first` on; null when the package has no summary yet. + private static DrawingGeometrySummary? ReadGeometry(SqliteDataReader reader, int first) => + reader.IsDBNull(first) ? null : new DrawingGeometrySummary + { + Version = reader.GetInt32(first), + Length = reader.GetDouble(first + 1), + Width = reader.GetDouble(first + 2), + Area = reader.GetDouble(first + 3), + Holes = reader.GetInt32(first + 4), + Error = reader.GetString(first + 5), + }; +} diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index c2f4385f..3581a936 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -103,6 +103,9 @@ public sealed partial class NestDatabase cutFirst, priority, stepAngle, startAngle, endAngle, allow180Equivalent, colorArgb """; + // The number of DrawingColumns, so joined columns can follow them. + private const int DrawingColumnCount = 22; + private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey"; // Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter. @@ -147,26 +150,27 @@ public sealed partial class NestDatabase var conditions = new List(); if (!query.IncludeArchived) conditions.Add("archived=0"); if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")"); - if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer"); + if (SharedListNames.Key(query.EffectiveCustomer).Length > 0) conditions.Add("customerKey=$customer"); // A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value. if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')"); if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')"); if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)"); if (query.CutType is not null) conditions.Add("cutType=$cutType"); if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst"); + AddRangeConditions(conditions, query.Filter); var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions); lock (_sync) { using var count = _connection.CreateCommand(); - count.CommandText = $"SELECT COUNT(*) FROM drawings {where}"; + count.CommandText = $"SELECT COUNT(*) FROM drawings {GeometryJoin} {where}"; AddDrawingFilterParameters(count, query); var total = checked((int)(long)count.ExecuteScalar()!); using var command = _connection.CreateCommand(); var direction = query.Descending ? "DESC" : "ASC"; command.CommandText = $""" - SELECT {DrawingColumns} FROM drawings {where} + SELECT {DrawingColumns}, {GeometryColumns} FROM drawings {GeometryJoin} {where} ORDER BY {DrawingSortColumn(query.Sort)} {direction}, id {direction} LIMIT $limit OFFSET $offset """; @@ -176,7 +180,11 @@ public sealed partial class NestDatabase var records = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) - records.Add(ReadDrawing(reader)); + { + var record = ReadDrawing(reader); + record.Geometry = ReadGeometry(reader, DrawingColumnCount); + records.Add(record); + } return new DrawingLibraryPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit }; } } @@ -367,8 +375,9 @@ public sealed partial class NestDatabase private static void AddDrawingFilterParameters(SqliteCommand command, DrawingLibraryQuery query) { AddSearchParameter(command, query.NormalizedSearch); - if (SharedListNames.Key(query.Customer).Length > 0) - command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer)); + if (SharedListNames.Key(query.EffectiveCustomer).Length > 0) + command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.EffectiveCustomer)); + AddRangeParameters(command, query.Filter); if (SharedListNames.Key(query.MaterialName).Length > 0) command.Parameters.AddWithValue("$material", SharedListNames.Key(query.MaterialName)); if (SharedListNames.Key(query.MaterialGrade).Length > 0) @@ -392,6 +401,8 @@ public sealed partial class NestDatabase DrawingSortField.Thickness => "thickness", DrawingSortField.CutType => "cutType", DrawingSortField.Revision => "revision", + DrawingSortField.Length => "g.length", + DrawingSortField.Holes => "g.holes", _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), }; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index cf6747d7..8d746b4f 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -344,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 18) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 19) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -455,6 +455,12 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = DrawingPriorityUpgradeToV18; command.ExecuteNonQuery(); } + if (version < 19) + { + // Measured package sizes, empty until the worker measures them. + command.CommandText = DrawingGeometrySchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs b/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs new file mode 100644 index 00000000..9885de52 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs @@ -0,0 +1,67 @@ +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Geometry; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Tests.Data; + +public class DrawingGeometrySummaryTests +{ + private static void Rectangle(CncProgram program, double x, double y, double width, double height) + { + program.Codes.Add(new RapidMove(new Vector(x, y))); + program.Codes.Add(new LinearMove(new Vector(x, y + height))); + program.Codes.Add(new LinearMove(new Vector(x + width, y + height))); + program.Codes.Add(new LinearMove(new Vector(x + width, y))); + program.Codes.Add(new LinearMove(new Vector(x, y))); + } + + [Fact] + public void Measure_ReportsTheBoundingSidesNetAreaAndHolesInInches() + { + var program = new CncProgram(); + Rectangle(program, 0, 0, 2, 5); + Rectangle(program, 0.5, 0.5, 1, 1); + Rectangle(program, 0.5, 3, 1, 1); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic plate", program), Units.Inches); + + Assert.True(summary.IsMeasured); + Assert.Equal((5.0, 2.0, 8.0, 2), (summary.Length, summary.Width, summary.Area, summary.Holes)); + Assert.Equal(DrawingGeometrySummary.CurrentVersion, summary.Version); + Assert.Null(summary.GetValidationError()); + } + + [Fact] + public void Measure_ConvertsMillimeterDrawingsToInches() + { + var program = new CncProgram(); + Rectangle(program, 0, 0, 50.8, 25.4); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic metric", program), Units.Millimeters); + + Assert.Equal((2.0, 1.0, 2.0, 0), (summary.Length, summary.Width, summary.Area, summary.Holes)); + } + + [Fact] + public void Measure_WithoutClosedCutGeometry_IsAFailedSummary() + { + var program = new CncProgram(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(3, 0))); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic line", program), Units.Inches); + + Assert.False(summary.IsMeasured); + Assert.Null(summary.GetValidationError()); + } + + [Fact] + public void Failed_TrimsLongErrorsToTheStoredLimit() + { + var summary = DrawingGeometrySummary.Failed(new string('x', 500)); + + Assert.Equal(DrawingGeometrySummary.MaxErrorLength, summary.Error.Length); + Assert.Null(summary.GetValidationError()); + } +} diff --git a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs index 8166c30c..8db44acf 100644 --- a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs @@ -99,6 +99,34 @@ public class DrawingLibraryBrowseSessionTests Assert.Equal((DrawingSortField.Name, true), (library.Queries[^1].Sort, library.Queries[^1].Descending)); } + [Fact] + public async Task Filter_IsSentWithTheNestFilterAndReturnsToTheFirstPage() + { + var library = new PagedLibrary(25); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + var filter = new DrawingLibraryFilter { Customer = "Synthetic Co", MaxLength = 4, MinHoles = 2 }; + + await session.SetFilterAsync(filter); + + var query = library.Queries[^1]; + Assert.Same(filter, query.Filter); + Assert.Equal(("Synthetic Co", "Synthetic Steel", 0), (query.EffectiveCustomer, query.MaterialName, query.Offset)); + await Assert.ThrowsAsync(() => session.SetFilterAsync(new DrawingLibraryFilter { MinHoles = 3, MaxHoles = 2 })); + Assert.Same(filter, session.Filter); + } + + [Fact] + public async Task Summary_NamesTheFilterWhenItLeavesNothing() + { + using var session = new DrawingLibraryBrowseSession(new PagedLibrary(0), null); + + await session.SetFilterAsync(new DrawingLibraryFilter { MaxLength = 4 }); + + Assert.Equal("No drawings match the filter.", session.Summary); + } + [Theory] [InlineData(false, "", "No library drawings match this nest's material and thickness. Tick Show all materials to see every drawing.")] [InlineData(true, "", "No drawings in the library.")] diff --git a/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs b/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs new file mode 100644 index 00000000..7efc3967 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs @@ -0,0 +1,62 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class DrawingLibraryFilterTests +{ + [Fact] + public void None_IsEmptyAndNeedsNoGeometry() + { + Assert.True(new DrawingLibraryFilter().IsEmpty); + Assert.False(DrawingLibraryFilter.None.NeedsGeometry); + Assert.Equal("", DrawingLibraryFilter.None.Describe()); + Assert.Null(DrawingLibraryFilter.None.GetValidationError()); + Assert.False(new DrawingLibraryFilter { IncludeBlankThickness = false }.IsEmpty); + Assert.True(new DrawingLibraryFilter { MinHoles = 0 }.NeedsGeometry); + } + + public static TheoryData InvalidFilters() => new() + { + new DrawingLibraryFilter { MinThickness = 0.25, MaxThickness = 0.1 }, + new DrawingLibraryFilter { MinThickness = -0.1 }, + new DrawingLibraryFilter { MaxLength = double.PositiveInfinity }, + new DrawingLibraryFilter { MinWidth = 2, MaxWidth = 1 }, + new DrawingLibraryFilter { MinHoles = 3, MaxHoles = 2 }, + new DrawingLibraryFilter { MaxHoles = -1 }, + new DrawingLibraryFilter { SavedFrom = new DateOnly(2026, 2, 1), SavedTo = new DateOnly(2026, 1, 1) }, + new DrawingLibraryFilter { Customer = new string('x', DrawingLibraryRules.MaxTextLength + 1) }, + }; + + [Theory] + [MemberData(nameof(InvalidFilters))] + public void InvalidRanges_AreReported(DrawingLibraryFilter filter) => + Assert.NotNull(filter.GetValidationError()); + + [Fact] + public void Describe_SummarizesEachLimit() + { + var filter = new DrawingLibraryFilter + { + Customer = " Synthetic Co ", + MinThickness = 0.0598, + MaxThickness = 0.1875, + MaxLength = 4, + MinWidth = 1, + MaxWidth = 1, + MinHoles = 2, + }; + + Assert.Equal("Customer Synthetic Co; Thickness 0.0598-0.1875 in (or none); Length up to 4 in; Width 1 in; Holes 2+", + filter.Describe()); + Assert.Equal("Has a thickness", new DrawingLibraryFilter { IncludeBlankThickness = false }.Describe()); + } + + [Fact] + public void EffectiveCustomer_PrefersTheQueryCustomer() + { + var filter = new DrawingLibraryFilter { Customer = "Filter Co" }; + + Assert.Equal("Filter Co", new DrawingLibraryQuery { Filter = filter }.EffectiveCustomer); + Assert.Equal("Query Co", new DrawingLibraryQuery { Customer = "Query Co", Filter = filter }.EffectiveCustomer); + } +} diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs index 01d92440..e4474791 100644 --- a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs @@ -65,6 +65,34 @@ public class RemoteDrawingLibraryTests Assert.Equal(7, page.Total); } + [Fact] + public async Task QueryAsync_SendsOnlyTheFilterLimitsThatAreSet() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0 })); + using var library = Library(handler); + + await library.QueryAsync(new DrawingLibraryQuery + { + Filter = new DrawingLibraryFilter + { + Customer = "Synthetic Co", + MinThickness = 0.0598, + MaxThickness = 0.1875, + IncludeBlankThickness = false, + MaxLength = 4, + MinHoles = 2, + SavedFrom = new DateOnly(2026, 1, 2), + SavedTo = new DateOnly(2026, 3, 4), + }, + }); + + Assert.Equal( + "http://server:8090/base/api/drawings/query?search=&sort=savedAt&order=desc&offset=0&limit=100" + + "&customer=Synthetic%20Co&minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false" + + "&maxLength=4&minHoles=2&savedFrom=2026-01-02&savedTo=2026-03-04", + handler.Requests[0].RequestUri!.AbsoluteUri); + } + public static TheoryData OutOfBoundsQueries() => new() { new DrawingLibraryQuery { Offset = -1 }, @@ -76,6 +104,9 @@ public class RemoteDrawingLibraryTests new DrawingLibraryQuery { CutType = (CutType)9 }, new DrawingLibraryQuery { Thickness = double.NaN }, new DrawingLibraryQuery { Thickness = -0.1 }, + new DrawingLibraryQuery { Filter = new DrawingLibraryFilter { MinLength = 5, MaxLength = 4 } }, + new DrawingLibraryQuery { Filter = new DrawingLibraryFilter { MinHoles = -1 } }, + new DrawingLibraryQuery { Filter = null! }, }; [Theory] diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs new file mode 100644 index 00000000..0b4392a9 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs @@ -0,0 +1,119 @@ +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Data; +using OpenNest.Forms; +using static OpenNest.WinForms.Tests.TestReflection; + +namespace OpenNest.WinForms.Tests.Forms; + +public class DrawingLibraryFilterFormTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public void ReadsGaugesFractionsAndCounts_AsAnInchFilter() + { + StaTestThread.Run( + () => + { + using var form = new DrawingLibraryFilterForm(DrawingLibraryFilter.None, new[] { "Synthetic Co" }); + Field(form, "customerBox").Text = " Synthetic Co "; + Field(form, "minThicknessBox").Text = "16 ga"; + Field(form, "maxThicknessBox").Text = "3/16"; + Field(form, "blankThicknessCheckBox").Checked = false; + Field(form, "maxLengthBox").Text = "4\""; + Field(form, "minWidthBox").Text = "1-1/2"; + Field(form, "minHolesBox").Text = "2"; + var from = Field(form, "savedFromPicker"); + from.Value = new DateTime(2026, 1, 2); + from.Checked = true; + + Assert.True(form.TryReadFilter(out var filter, out var error), error); + + Assert.Equal(new DrawingLibraryFilter + { + Customer = "Synthetic Co", + MinThickness = 0.0598, + MaxThickness = 0.1875, + IncludeBlankThickness = false, + MaxLength = 4, + MinWidth = 1.5, + MinHoles = 2, + SavedFrom = new DateOnly(2026, 1, 2), + }, filter); + }, + TestTimeout, + "The filter dialog read test did not complete."); + } + + [Theory] + [InlineData("minThicknessBox", "thick")] + [InlineData("maxLengthBox", "16 ga")] + [InlineData("maxLengthBox", "-1")] + [InlineData("minHolesBox", "1.5")] + public void UnreadableBox_KeepsTheDialogOpenWithTheReason(string box, string text) + { + StaTestThread.Run( + () => + { + using var form = new DrawingLibraryFilterForm(DrawingLibraryFilter.None); + form.Show(); + ((Control)typeof(DrawingLibraryFilterForm).GetField(box, System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)!.GetValue(form)!).Text = text; + + Field