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