mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-13 06:51:09 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1382d00496
commit
b9fc618a50
38 files changed
+2170
-43
No files matched your search
@@ -0,0 +1,108 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Error"/> set so it is not measured again.
|
||||
/// </summary>
|
||||
public sealed record DrawingGeometrySummary
|
||||
{
|
||||
/// <summary>Raised when the measurement changes, so the worker measures every package again.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public const int MaxErrorLength = 200;
|
||||
|
||||
/// <summary>The <see cref="CurrentVersion"/> that measured this package.</summary>
|
||||
public int Version { get; init; }
|
||||
|
||||
/// <summary>The longer side of the material's bounding box, in inches.</summary>
|
||||
public double Length { get; init; }
|
||||
|
||||
/// <summary>The shorter side of the material's bounding box, in inches.</summary>
|
||||
public double Width { get; init; }
|
||||
|
||||
/// <summary>Net material area (outline less holes), in square inches.</summary>
|
||||
public double Area { get; init; }
|
||||
|
||||
/// <summary>Closed contours inside the outline: holes and other cutouts.</summary>
|
||||
public int Holes { get; init; }
|
||||
|
||||
/// <summary>Why the package could not be measured; blank for a measured package.</summary>
|
||||
public string Error { get; init; } = "";
|
||||
|
||||
public bool IsMeasured => Error.Length == 0;
|
||||
|
||||
/// <summary>The first invalid field, or null when the summary may be stored.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Records why a package could not be measured.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
|
||||
/// <summary>True when the nest filter is lifted and every active drawing is listed.</summary>
|
||||
public bool ShowAll { get; private set; }
|
||||
|
||||
/// <summary>The Filter dialog's customer and ranges, applied with the nest filter; <see cref="DrawingLibraryFilter.None"/> by default.</summary>
|
||||
public DrawingLibraryFilter Filter { get; private set; } = DrawingLibraryFilter.None;
|
||||
|
||||
/// <summary>True when the latest request narrows by the nest's material, grade or thickness.</summary>
|
||||
public bool IsFilteredToNest =>
|
||||
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
|
||||
@@ -45,7 +48,8 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
|
||||
/// <inheritdoc />
|
||||
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 : BrowseSession<DrawingLibraryPa
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>Applies the Filter dialog's choices and returns to the first page.</summary>
|
||||
public Task<bool> 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<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_library.QueryAsync(
|
||||
new DrawingLibraryQuery
|
||||
@@ -64,6 +79,7 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
|
||||
MaterialName = ShowAll ? "" : MaterialName,
|
||||
MaterialGrade = ShowAll ? "" : MaterialGrade,
|
||||
Thickness = ShowAll ? null : Thickness,
|
||||
Filter = Filter,
|
||||
Sort = Sort,
|
||||
Descending = Descending,
|
||||
Offset = Offset,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="DrawingGeometrySummary"/>). The browse session sends it with every query.
|
||||
/// </summary>
|
||||
public sealed record DrawingLibraryFilter
|
||||
{
|
||||
public static DrawingLibraryFilter None { get; } = new();
|
||||
|
||||
/// <summary>Exact customer, compared by <see cref="SharedListNames.Key"/>. Blank means any.</summary>
|
||||
public string Customer { get; init; } = "";
|
||||
|
||||
public double? MinThickness { get; init; }
|
||||
|
||||
public double? MaxThickness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IncludeBlankThickness { get; init; } = true;
|
||||
|
||||
/// <summary>Limits on the longer side of the bounding box.</summary>
|
||||
public double? MinLength { get; init; }
|
||||
|
||||
public double? MaxLength { get; init; }
|
||||
|
||||
/// <summary>Limits on the shorter side of the bounding box.</summary>
|
||||
public double? MinWidth { get; init; }
|
||||
|
||||
public double? MaxWidth { get; init; }
|
||||
|
||||
public int? MinHoles { get; init; }
|
||||
|
||||
public int? MaxHoles { get; init; }
|
||||
|
||||
/// <summary>First and last day saved, inclusive, in the server's local dates.</summary>
|
||||
public DateOnly? SavedFrom { get; init; }
|
||||
|
||||
public DateOnly? SavedTo { get; init; }
|
||||
|
||||
public bool IsEmpty => this == None;
|
||||
|
||||
/// <summary>True when a size or hole limit is set, so only measured drawings can match.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The first invalid range, or null when the filter may be sent.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>A one-line summary for the library's filter label, for example "Thickness 0.0598-0.1875 in; Holes 2+".</summary>
|
||||
public string Describe()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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);
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public sealed class DrawingLibraryQuery
|
||||
|
||||
public bool IncludeArchived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thickness, size, hole and saved-date ranges; <see cref="DrawingLibraryFilter.None"/>
|
||||
/// means none. Its customer applies only when <see cref="Customer"/> is blank.
|
||||
/// </summary>
|
||||
public DrawingLibraryFilter Filter { get; init; } = DrawingLibraryFilter.None;
|
||||
|
||||
/// <summary>The customer matched: <see cref="Customer"/>, or the filter's when that is blank.</summary>
|
||||
public string EffectiveCustomer =>
|
||||
string.IsNullOrWhiteSpace(Customer) ? (Filter?.Customer ?? "") : Customer;
|
||||
|
||||
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,10 @@ public sealed class DrawingLibraryRecord
|
||||
|
||||
/// <summary>Who saved this revision, as reported by the client.</summary>
|
||||
public string SavedBy { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public DrawingGeometrySummary? Geometry { get; set; }
|
||||
}
|
||||
@@ -11,4 +11,10 @@ public enum DrawingSortField
|
||||
Thickness,
|
||||
CutType,
|
||||
Revision,
|
||||
|
||||
/// <summary>The measured longer side; unmeasured drawings sort as smallest.</summary>
|
||||
Length,
|
||||
|
||||
/// <summary>The measured hole count; unmeasured drawings sort as fewest.</summary>
|
||||
Holes,
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The server worker's side of library measurement (<c>/api/drawing-packages</c>): lists the
|
||||
/// packages of current drawings not yet measured at a version, downloads one by its hash and
|
||||
/// stores its <see cref="DrawingGeometrySummary"/>. A server without these routes raises
|
||||
/// <see cref="ServerTooOldException"/>; other failures surface as <see cref="IOException"/>.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Up to <paramref name="limit"/> package hashes with no summary at <paramref name="version"/> or later.</summary>
|
||||
public async Task<IReadOnlyList<string>> 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<List<string>>(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;
|
||||
}
|
||||
|
||||
/// <summary>The package stored under <paramref name="sha256"/>, or null when there is none.</summary>
|
||||
public async Task<byte[]?> 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);
|
||||
}
|
||||
|
||||
/// <summary>Stores a package's summary; false when the server no longer has the package.</summary>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>True for a lower-case hex SHA-256, the form the server stores.</summary>
|
||||
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();
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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<InvalidOperationException>(() => 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<List<string>>("/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<List<string>>("/api/drawing-packages/unmeasured?version=1&limit=10"));
|
||||
// A newer measurement version lists it again.
|
||||
Assert.Equal(expected, await client.GetFromJsonAsync<List<string>>("/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<DrawingGeometryBackfill>.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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using OpenNest.Data;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Server.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// Measures library drawing packages the server lists as unmeasured and stores each
|
||||
/// <see cref="DrawingGeometrySummary"/>, 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.
|
||||
/// </summary>
|
||||
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<DrawingGeometryBackfill> logger)
|
||||
{
|
||||
_server = server;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Measures one batch; returns how many summaries were stored (0 when none were waiting).</summary>
|
||||
public async Task<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>Reads and measures one package; a failure becomes a failed summary.</summary>
|
||||
public static async Task<DrawingGeometrySummary> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <see cref="DrawingGeometryBackfill"/> beside the nest job worker: batch after batch
|
||||
/// while packages are waiting, then every <see cref="WorkerOptions.GeometryPollInterval"/>.
|
||||
/// </summary>
|
||||
public sealed class DrawingGeometryBackfillService : BackgroundService
|
||||
{
|
||||
private readonly DrawingGeometryBackfill _backfill;
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ILogger<DrawingGeometryBackfillService> _logger;
|
||||
|
||||
public DrawingGeometryBackfillService(DrawingGeometryBackfill backfill, WorkerOptions options,
|
||||
ILogger<DrawingGeometryBackfillService> 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RemoteNestJobs>(), sp.GetRequiredService<ILogger<NestJobWorker>>()));
|
||||
builder.Services.AddHostedService<NestJobWorkerService>();
|
||||
// Measures library drawings for the size and hole filters; see DrawingGeometryBackfill.
|
||||
builder.Services.AddSingleton(_ => new RemoteDrawingGeometry(options.ServerUrl));
|
||||
builder.Services.AddSingleton<DrawingGeometryBackfill>();
|
||||
builder.Services.AddHostedService<DrawingGeometryBackfillService>();
|
||||
// A running job is given back, not abandoned, when the container stops.
|
||||
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
|
||||
builder.Build().Run();
|
||||
@@ -13,6 +13,9 @@ public sealed class WorkerOptions
|
||||
/// <summary>How long to wait before asking again when no job was queued.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>How long library measurement waits before asking again when nothing was waiting.</summary>
|
||||
public TimeSpan GeometryPollInterval { get; init; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Touched every round; the container health check fails when it goes stale.</summary>
|
||||
public string AliveFile { get; init; } = Path.Combine(Path.GetTempPath(), "opennest-worker.alive");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Reads and validates one metadata JSON object.</summary>
|
||||
public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error)
|
||||
{
|
||||
|
||||
@@ -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<DrawingGeometrySummary>(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());
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)";
|
||||
|
||||
/// <summary>
|
||||
/// Up to <paramref name="limit"/> distinct package hashes of current drawings (archived ones
|
||||
/// included) with no summary at <paramref name="version"/> or later, in hash order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> 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<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
hashes.Add(reader.GetString(0));
|
||||
return hashes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The package stored under a hash, or null.</summary>
|
||||
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[];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> conditions, DrawingLibraryFilter filter)
|
||||
{
|
||||
if (filter.MinThickness is not null || filter.MaxThickness is not null)
|
||||
{
|
||||
var range = new List<string> { "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),
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<DrawingLibraryRecord>();
|
||||
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."),
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>(() => 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.")]
|
||||
|
||||
@@ -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<DrawingLibraryFilter> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<DrawingLibraryRecord>(), 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<DrawingLibraryQuery> 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]
|
||||
|
||||
@@ -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<ComboBox>(form, "customerBox").Text = " Synthetic Co ";
|
||||
Field<ThicknessComboBox>(form, "minThicknessBox").Text = "16 ga";
|
||||
Field<ThicknessComboBox>(form, "maxThicknessBox").Text = "3/16";
|
||||
Field<CheckBox>(form, "blankThicknessCheckBox").Checked = false;
|
||||
Field<TextBox>(form, "maxLengthBox").Text = "4\"";
|
||||
Field<TextBox>(form, "minWidthBox").Text = "1-1/2";
|
||||
Field<TextBox>(form, "minHolesBox").Text = "2";
|
||||
var from = Field<DateTimePicker>(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<Button>(form, "okButton").PerformClick();
|
||||
|
||||
Assert.Equal(DialogResult.None, form.DialogResult);
|
||||
Assert.NotEqual("", Field<Label>(form, "errorLabel").Text);
|
||||
Assert.Same(DrawingLibraryFilter.None, form.Filter);
|
||||
},
|
||||
TestTimeout,
|
||||
"The filter dialog refusal test did not complete.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReversedRange_IsRefused_AndClearEmptiesEveryBox()
|
||||
{
|
||||
StaTestThread.Run(
|
||||
() =>
|
||||
{
|
||||
var start = new DrawingLibraryFilter { MinHoles = 3, MaxLength = 4, SavedTo = new DateOnly(2026, 5, 6) };
|
||||
using var form = new DrawingLibraryFilterForm(start);
|
||||
form.Show();
|
||||
Assert.Equal("4", Field<TextBox>(form, "maxLengthBox").Text);
|
||||
Assert.True(Field<DateTimePicker>(form, "savedToPicker").Checked);
|
||||
Field<TextBox>(form, "maxHolesBox").Text = "2";
|
||||
|
||||
Field<Button>(form, "okButton").PerformClick();
|
||||
Assert.Equal(DialogResult.None, form.DialogResult);
|
||||
|
||||
Field<Button>(form, "clearButton").PerformClick();
|
||||
Field<Button>(form, "okButton").PerformClick();
|
||||
|
||||
Assert.Equal(DialogResult.OK, form.DialogResult);
|
||||
Assert.True(form.Filter.IsEmpty);
|
||||
},
|
||||
TestTimeout,
|
||||
"The filter dialog range test did not complete.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", null)]
|
||||
[InlineData("4", 4.0)]
|
||||
[InlineData(" 3/16 in ", 0.1875)]
|
||||
[InlineData("2-1/2\"", 2.5)]
|
||||
[InlineData("#16", double.NaN)]
|
||||
[InlineData("abc", double.NaN)]
|
||||
public void TryParseLength_ReadsInchesAndRefusesGauges(string text, double? expected)
|
||||
{
|
||||
var read = DrawingLibraryFilterForm.TryParseLength(text, out var value);
|
||||
|
||||
if (expected is double number && double.IsNaN(number))
|
||||
{
|
||||
Assert.False(read);
|
||||
return;
|
||||
}
|
||||
Assert.True(read);
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
}
|
||||
@@ -538,6 +538,46 @@ public class DrawingLibraryFormTests
|
||||
"The library-only dialog test did not complete.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterButton_SendsTheChosenFilter_AndListsMeasuredSizeAndHoles()
|
||||
{
|
||||
StaTestThread.Run(
|
||||
() =>
|
||||
{
|
||||
var library = new FakeLibrary();
|
||||
var record = library.Add("SYN-WASHER", "Synthetic Steel", "Grade A", 0.1046, Units.Inches, "");
|
||||
record.Geometry = new DrawingGeometrySummary { Version = 1, Length = 3.5, Width = 2, Area = 6, Holes = 2 };
|
||||
var chosen = new DrawingLibraryFilter { MinThickness = 0.0598, MaxThickness = 0.1875, MaxLength = 4, MinHoles = 2 };
|
||||
DrawingLibraryFilter offered = null!;
|
||||
IReadOnlyList<string> customers = null!;
|
||||
|
||||
using var form = new DrawingLibraryForm(library, Server, nest: null);
|
||||
form.View.LoadPreview = (_, _) => Task.FromResult<Drawing>(null!);
|
||||
form.View.ChooseFilter = (filter, names) => { offered = filter; customers = names; return chosen; };
|
||||
form.Show();
|
||||
var grid = Field<DataGridView>(form, "drawingGrid");
|
||||
PumpUntilListed(grid, "the library page");
|
||||
Assert.Equal("3.5 x 2", grid.Rows[0].Cells["sizeColumn"].Value);
|
||||
Assert.Equal(2, grid.Rows[0].Cells["holesColumn"].Value);
|
||||
|
||||
Run(form.View.ChangeFilterAsync());
|
||||
|
||||
Assert.Same(DrawingLibraryFilter.None, offered);
|
||||
Assert.Equal(new[] { "Synthetic customer" }, customers);
|
||||
Assert.Same(chosen, library.Queries[^1].Filter);
|
||||
Assert.Equal("Filter (on)...", Field<Button>(form, "filterButton").Text);
|
||||
Assert.EndsWith("Filter: Thickness 0.0598-0.1875 in (or none); Length up to 4 in; Holes 2+.",
|
||||
Field<Label>(form, "filterLabel").Text);
|
||||
|
||||
// Choosing the same filter again sends nothing.
|
||||
var sent = library.Queries.Count;
|
||||
Run(form.View.ChangeFilterAsync());
|
||||
Assert.Equal(sent, library.Queries.Count);
|
||||
},
|
||||
TestTimeout,
|
||||
"The drawing library filter test did not complete.");
|
||||
}
|
||||
|
||||
private static Nest MatchingNest() => new("Synthetic job")
|
||||
{
|
||||
Units = Units.Inches,
|
||||
|
||||
+43
-6
@@ -42,6 +42,7 @@ namespace OpenNest.Controls
|
||||
searchLabel = new System.Windows.Forms.Label();
|
||||
searchBox = new System.Windows.Forms.TextBox();
|
||||
showAllCheckBox = new System.Windows.Forms.CheckBox();
|
||||
filterButton = new System.Windows.Forms.Button();
|
||||
filterLabel = new System.Windows.Forms.Label();
|
||||
splitContainer = new System.Windows.Forms.SplitContainer();
|
||||
browseTable = new System.Windows.Forms.TableLayoutPanel();
|
||||
@@ -51,6 +52,8 @@ namespace OpenNest.Controls
|
||||
materialColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
gradeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
thicknessColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
sizeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
holesColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
cutTypeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
cutFirstColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
revisionColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
@@ -268,15 +271,17 @@ namespace OpenNest.Controls
|
||||
// filterTable
|
||||
//
|
||||
filterTable.AutoSize = true;
|
||||
filterTable.ColumnCount = 3;
|
||||
filterTable.ColumnCount = 4;
|
||||
filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||
filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
filterTable.Controls.Add(searchLabel, 0, 0);
|
||||
filterTable.Controls.Add(searchBox, 1, 0);
|
||||
filterTable.Controls.Add(showAllCheckBox, 2, 0);
|
||||
filterTable.Controls.Add(filterButton, 2, 0);
|
||||
filterTable.Controls.Add(showAllCheckBox, 3, 0);
|
||||
filterTable.Controls.Add(filterLabel, 1, 1);
|
||||
filterTable.SetColumnSpan(filterLabel, 2);
|
||||
filterTable.SetColumnSpan(filterLabel, 3);
|
||||
filterTable.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
filterTable.Margin = new System.Windows.Forms.Padding(0, 0, 0, 6);
|
||||
filterTable.Name = "filterTable";
|
||||
@@ -307,11 +312,22 @@ namespace OpenNest.Controls
|
||||
showAllCheckBox.AutoSize = true;
|
||||
showAllCheckBox.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3);
|
||||
showAllCheckBox.Name = "showAllCheckBox";
|
||||
showAllCheckBox.TabIndex = 2;
|
||||
showAllCheckBox.TabIndex = 3;
|
||||
showAllCheckBox.Text = "Show all materials";
|
||||
showAllCheckBox.UseVisualStyleBackColor = true;
|
||||
showAllCheckBox.CheckedChanged += ShowAllCheckBox_CheckedChanged;
|
||||
//
|
||||
// filterButton
|
||||
//
|
||||
filterButton.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
filterButton.AutoSize = true;
|
||||
filterButton.Margin = new System.Windows.Forms.Padding(6, 2, 3, 2);
|
||||
filterButton.Name = "filterButton";
|
||||
filterButton.TabIndex = 2;
|
||||
filterButton.Text = "Filter...";
|
||||
filterButton.UseVisualStyleBackColor = true;
|
||||
filterButton.Click += FilterButton_Click;
|
||||
//
|
||||
// filterLabel
|
||||
//
|
||||
filterLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
@@ -319,7 +335,7 @@ namespace OpenNest.Controls
|
||||
filterLabel.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
filterLabel.Margin = new System.Windows.Forms.Padding(3, 2, 3, 0);
|
||||
filterLabel.Name = "filterLabel";
|
||||
filterLabel.TabIndex = 3;
|
||||
filterLabel.TabIndex = 4;
|
||||
//
|
||||
// splitContainer
|
||||
//
|
||||
@@ -349,7 +365,7 @@ namespace OpenNest.Controls
|
||||
//
|
||||
drawingGrid.AllowUserToAddRows = false;
|
||||
drawingGrid.AllowUserToDeleteRows = false;
|
||||
drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nameColumn, customerColumn, materialColumn, gradeColumn, thicknessColumn, cutTypeColumn, cutFirstColumn, revisionColumn, savedColumn });
|
||||
drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nameColumn, customerColumn, materialColumn, gradeColumn, thicknessColumn, sizeColumn, holesColumn, cutTypeColumn, cutFirstColumn, revisionColumn, savedColumn });
|
||||
drawingGrid.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
drawingGrid.Margin = new System.Windows.Forms.Padding(0);
|
||||
drawingGrid.MultiSelect = false;
|
||||
@@ -402,6 +418,24 @@ namespace OpenNest.Controls
|
||||
thicknessColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic;
|
||||
thicknessColumn.Width = 80;
|
||||
//
|
||||
// sizeColumn
|
||||
//
|
||||
sizeColumn.DefaultCellStyle = rightAligned;
|
||||
sizeColumn.HeaderText = "Size (in)";
|
||||
sizeColumn.Name = "sizeColumn";
|
||||
sizeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic;
|
||||
sizeColumn.ToolTipText = "Longer x shorter side, measured by the server; blank until measured";
|
||||
sizeColumn.Width = 100;
|
||||
//
|
||||
// holesColumn
|
||||
//
|
||||
holesColumn.DefaultCellStyle = rightAligned;
|
||||
holesColumn.HeaderText = "Holes";
|
||||
holesColumn.Name = "holesColumn";
|
||||
holesColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic;
|
||||
holesColumn.ToolTipText = "Closed cutouts inside the outline, measured by the server";
|
||||
holesColumn.Width = 55;
|
||||
//
|
||||
// cutTypeColumn
|
||||
//
|
||||
cutTypeColumn.HeaderText = "Cut type";
|
||||
@@ -854,6 +888,7 @@ namespace OpenNest.Controls
|
||||
private System.Windows.Forms.Label searchLabel;
|
||||
private System.Windows.Forms.TextBox searchBox;
|
||||
private System.Windows.Forms.CheckBox showAllCheckBox;
|
||||
private System.Windows.Forms.Button filterButton;
|
||||
private System.Windows.Forms.Label filterLabel;
|
||||
private System.Windows.Forms.SplitContainer splitContainer;
|
||||
private System.Windows.Forms.TableLayoutPanel browseTable;
|
||||
@@ -863,6 +898,8 @@ namespace OpenNest.Controls
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn materialColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn gradeColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn thicknessColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn sizeColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn holesColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn cutTypeColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn cutFirstColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn revisionColumn;
|
||||
|
||||
@@ -72,6 +72,12 @@ namespace OpenNest.Controls
|
||||
/// </summary>
|
||||
internal Func<(bool Ok, DrawingSetRecord Listed)> ChooseSet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shows the Filter dialog for the current filter and the customers to offer; returns the
|
||||
/// chosen filter, or null when canceled. Replaced by tests.
|
||||
/// </summary>
|
||||
internal Func<DrawingLibraryFilter, IReadOnlyList<string>, DrawingLibraryFilter> ChooseFilter { get; set; }
|
||||
|
||||
/// <summary>Shows the set editor and returns the saved record, or null; replaced by tests.</summary>
|
||||
internal Func<DrawingSetEditSession, DrawingSetRecord> EditSet { get; set; }
|
||||
|
||||
@@ -151,6 +157,11 @@ namespace OpenNest.Controls
|
||||
_ = form.ShowReleasesAsync(drawing: record);
|
||||
form.ShowDialog(DialogOwner);
|
||||
};
|
||||
ChooseFilter = (filter, customers) =>
|
||||
{
|
||||
using var form = new DrawingLibraryFilterForm(filter, customers);
|
||||
return form.ShowDialog(DialogOwner) == DialogResult.OK ? form.Filter : null;
|
||||
};
|
||||
LoadPreview = async (record, token) =>
|
||||
{
|
||||
var bytes = await library.GetRevisionPackageAsync(record.Id, record.Revision, token);
|
||||
@@ -164,6 +175,8 @@ namespace OpenNest.Controls
|
||||
materialColumn.Tag = DrawingSortField.MaterialName;
|
||||
gradeColumn.Tag = DrawingSortField.MaterialGrade;
|
||||
thicknessColumn.Tag = DrawingSortField.Thickness;
|
||||
sizeColumn.Tag = DrawingSortField.Length;
|
||||
holesColumn.Tag = DrawingSortField.Holes;
|
||||
cutTypeColumn.Tag = DrawingSortField.CutType;
|
||||
revisionColumn.Tag = DrawingSortField.Revision;
|
||||
savedColumn.Tag = DrawingSortField.SavedAt;
|
||||
@@ -222,6 +235,26 @@ namespace OpenNest.Controls
|
||||
await RunAsync(() => session.SetShowAllAsync(showAll));
|
||||
}
|
||||
|
||||
private async void FilterButton_Click(object sender, EventArgs e) => await ChangeFilterAsync();
|
||||
|
||||
/// <summary>Shows the Filter dialog and, unless canceled, lists the first page that matches.</summary>
|
||||
internal async Task ChangeFilterAsync()
|
||||
{
|
||||
var customers = (session.Page?.Items ?? Array.Empty<DrawingLibraryRecord>())
|
||||
.Select(record => record.Customer.Trim())
|
||||
.Append(session.Filter.Customer.Trim())
|
||||
.Where(customer => customer.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(customer => customer, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
var chosen = ChooseFilter(session.Filter, customers);
|
||||
if (chosen == null || chosen == session.Filter)
|
||||
return;
|
||||
var request = session.SetFilterAsync(chosen);
|
||||
UpdateFilterLabel(showAllCheckBox.Checked);
|
||||
await RunAsync(() => request);
|
||||
}
|
||||
|
||||
private async void DrawingGrid_ColumnHeaderMouseClick(
|
||||
object sender, DataGridViewCellMouseEventArgs e) =>
|
||||
await pager.SortByHeaderAsync(e);
|
||||
@@ -759,6 +792,8 @@ namespace OpenNest.Controls
|
||||
record.MaterialName,
|
||||
record.MaterialGrade,
|
||||
FormatThickness(record.Thickness),
|
||||
FormatSize(record.Geometry),
|
||||
record.Geometry is { IsMeasured: true } geometry ? geometry.Holes : null,
|
||||
record.CutType.ToString(),
|
||||
record.Settings?.CutFirst == true ? "Yes" : "",
|
||||
record.Revision,
|
||||
@@ -781,20 +816,23 @@ namespace OpenNest.Controls
|
||||
}
|
||||
|
||||
private void UpdateFilterLabel(bool showAll = false)
|
||||
{
|
||||
var text = BaseFilterText(showAll);
|
||||
filterLabel.Text = session.Filter.IsEmpty ? text : $"{text.TrimEnd('.')}. Filter: {session.Filter.Describe()}.";
|
||||
filterButton.Text = session.Filter.IsEmpty ? "Filter..." : "Filter (on)...";
|
||||
}
|
||||
|
||||
private string BaseFilterText(bool showAll)
|
||||
{
|
||||
if (nest == null)
|
||||
{
|
||||
filterLabel.Text = AddToRelease != null
|
||||
return AddToRelease != null
|
||||
? "Showing every active drawing. Double-click one to add it to the release."
|
||||
: "Showing every active drawing. Double-click one to edit it.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.IsFilteredToNest && !showAll)
|
||||
{
|
||||
filterLabel.Text = "This nest has no material or thickness; showing every drawing.";
|
||||
return;
|
||||
}
|
||||
return "This nest has no material or thickness; showing every drawing.";
|
||||
|
||||
var parts = new[]
|
||||
{
|
||||
@@ -802,11 +840,17 @@ namespace OpenNest.Controls
|
||||
session.MaterialName,
|
||||
session.MaterialGrade,
|
||||
}.Where(part => part.Length > 0);
|
||||
filterLabel.Text = showAll
|
||||
return showAll
|
||||
? "Showing every drawing, including other materials and thicknesses."
|
||||
: $"Showing drawings for this nest: {string.Join(" ", parts)}";
|
||||
}
|
||||
|
||||
// "4.25 x 2" in inches; blank until the server has measured the drawing, "?" when it could not.
|
||||
private static string FormatSize(DrawingGeometrySummary geometry) =>
|
||||
geometry == null ? ""
|
||||
: !geometry.IsMeasured ? "?"
|
||||
: $"{geometry.Length.ToString("0.###", CultureInfo.CurrentCulture)} x {geometry.Width.ToString("0.###", CultureInfo.CurrentCulture)}";
|
||||
|
||||
private static string FormatThickness(double value) =>
|
||||
value.ToString("0.####", CultureInfo.CurrentCulture);
|
||||
|
||||
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class DrawingLibraryFilterForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
rootTable = new System.Windows.Forms.TableLayoutPanel();
|
||||
customerLabel = new System.Windows.Forms.Label();
|
||||
customerBox = new System.Windows.Forms.ComboBox();
|
||||
thicknessLabel = new System.Windows.Forms.Label();
|
||||
minThicknessBox = new OpenNest.Controls.ThicknessComboBox();
|
||||
thicknessToLabel = new System.Windows.Forms.Label();
|
||||
maxThicknessBox = new OpenNest.Controls.ThicknessComboBox();
|
||||
blankThicknessCheckBox = new System.Windows.Forms.CheckBox();
|
||||
lengthLabel = new System.Windows.Forms.Label();
|
||||
minLengthBox = new System.Windows.Forms.TextBox();
|
||||
lengthToLabel = new System.Windows.Forms.Label();
|
||||
maxLengthBox = new System.Windows.Forms.TextBox();
|
||||
widthLabel = new System.Windows.Forms.Label();
|
||||
minWidthBox = new System.Windows.Forms.TextBox();
|
||||
widthToLabel = new System.Windows.Forms.Label();
|
||||
maxWidthBox = new System.Windows.Forms.TextBox();
|
||||
holesLabel = new System.Windows.Forms.Label();
|
||||
minHolesBox = new System.Windows.Forms.TextBox();
|
||||
holesToLabel = new System.Windows.Forms.Label();
|
||||
maxHolesBox = new System.Windows.Forms.TextBox();
|
||||
savedLabel = new System.Windows.Forms.Label();
|
||||
savedFromPicker = new System.Windows.Forms.DateTimePicker();
|
||||
savedToLabel = new System.Windows.Forms.Label();
|
||||
savedToPicker = new System.Windows.Forms.DateTimePicker();
|
||||
noteLabel = new System.Windows.Forms.Label();
|
||||
errorLabel = new System.Windows.Forms.Label();
|
||||
buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
cancelButton = new System.Windows.Forms.Button();
|
||||
okButton = new System.Windows.Forms.Button();
|
||||
clearButton = new System.Windows.Forms.Button();
|
||||
rootTable.SuspendLayout();
|
||||
buttonPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
// rootTable
|
||||
//
|
||||
rootTable.AutoSize = true;
|
||||
rootTable.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
|
||||
rootTable.ColumnCount = 4;
|
||||
rootTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
rootTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 150F));
|
||||
rootTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
rootTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 150F));
|
||||
rootTable.Controls.Add(customerLabel, 0, 0);
|
||||
rootTable.Controls.Add(customerBox, 1, 0);
|
||||
rootTable.Controls.Add(thicknessLabel, 0, 1);
|
||||
rootTable.Controls.Add(minThicknessBox, 1, 1);
|
||||
rootTable.Controls.Add(thicknessToLabel, 2, 1);
|
||||
rootTable.Controls.Add(maxThicknessBox, 3, 1);
|
||||
rootTable.Controls.Add(blankThicknessCheckBox, 1, 2);
|
||||
rootTable.Controls.Add(lengthLabel, 0, 3);
|
||||
rootTable.Controls.Add(minLengthBox, 1, 3);
|
||||
rootTable.Controls.Add(lengthToLabel, 2, 3);
|
||||
rootTable.Controls.Add(maxLengthBox, 3, 3);
|
||||
rootTable.Controls.Add(widthLabel, 0, 4);
|
||||
rootTable.Controls.Add(minWidthBox, 1, 4);
|
||||
rootTable.Controls.Add(widthToLabel, 2, 4);
|
||||
rootTable.Controls.Add(maxWidthBox, 3, 4);
|
||||
rootTable.Controls.Add(holesLabel, 0, 5);
|
||||
rootTable.Controls.Add(minHolesBox, 1, 5);
|
||||
rootTable.Controls.Add(holesToLabel, 2, 5);
|
||||
rootTable.Controls.Add(maxHolesBox, 3, 5);
|
||||
rootTable.Controls.Add(savedLabel, 0, 6);
|
||||
rootTable.Controls.Add(savedFromPicker, 1, 6);
|
||||
rootTable.Controls.Add(savedToLabel, 2, 6);
|
||||
rootTable.Controls.Add(savedToPicker, 3, 6);
|
||||
rootTable.Controls.Add(noteLabel, 0, 7);
|
||||
rootTable.Controls.Add(errorLabel, 0, 8);
|
||||
rootTable.Controls.Add(buttonPanel, 0, 9);
|
||||
rootTable.SetColumnSpan(customerBox, 3);
|
||||
rootTable.SetColumnSpan(blankThicknessCheckBox, 3);
|
||||
rootTable.SetColumnSpan(noteLabel, 4);
|
||||
rootTable.SetColumnSpan(errorLabel, 4);
|
||||
rootTable.SetColumnSpan(buttonPanel, 4);
|
||||
rootTable.Location = new System.Drawing.Point(12, 12);
|
||||
rootTable.Name = "rootTable";
|
||||
rootTable.RowCount = 10;
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
rootTable.TabIndex = 0;
|
||||
//
|
||||
// customerLabel
|
||||
//
|
||||
customerLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
customerLabel.AutoSize = true;
|
||||
customerLabel.Name = "customerLabel";
|
||||
customerLabel.Text = "Customer:";
|
||||
//
|
||||
// customerBox
|
||||
//
|
||||
customerBox.AutoCompleteMode = System.Windows.Forms.AutoCompleteMode.SuggestAppend;
|
||||
customerBox.AutoCompleteSource = System.Windows.Forms.AutoCompleteSource.ListItems;
|
||||
customerBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
customerBox.Name = "customerBox";
|
||||
customerBox.TabIndex = 1;
|
||||
//
|
||||
// thicknessLabel
|
||||
//
|
||||
thicknessLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
thicknessLabel.AutoSize = true;
|
||||
thicknessLabel.Name = "thicknessLabel";
|
||||
thicknessLabel.Text = "Thickness (in):";
|
||||
//
|
||||
// minThicknessBox
|
||||
//
|
||||
minThicknessBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
minThicknessBox.Name = "minThicknessBox";
|
||||
minThicknessBox.TabIndex = 2;
|
||||
//
|
||||
// thicknessToLabel
|
||||
//
|
||||
thicknessToLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
thicknessToLabel.AutoSize = true;
|
||||
thicknessToLabel.Name = "thicknessToLabel";
|
||||
thicknessToLabel.Text = "to";
|
||||
//
|
||||
// maxThicknessBox
|
||||
//
|
||||
maxThicknessBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
maxThicknessBox.Name = "maxThicknessBox";
|
||||
maxThicknessBox.TabIndex = 3;
|
||||
//
|
||||
// blankThicknessCheckBox
|
||||
//
|
||||
blankThicknessCheckBox.AutoSize = true;
|
||||
blankThicknessCheckBox.Checked = true;
|
||||
blankThicknessCheckBox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
blankThicknessCheckBox.Name = "blankThicknessCheckBox";
|
||||
blankThicknessCheckBox.TabIndex = 4;
|
||||
blankThicknessCheckBox.Text = "Include drawings saved without a thickness";
|
||||
blankThicknessCheckBox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// lengthLabel
|
||||
//
|
||||
lengthLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
lengthLabel.AutoSize = true;
|
||||
lengthLabel.Name = "lengthLabel";
|
||||
lengthLabel.Text = "Longer side (in):";
|
||||
//
|
||||
// minLengthBox
|
||||
//
|
||||
minLengthBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
minLengthBox.Name = "minLengthBox";
|
||||
minLengthBox.TabIndex = 5;
|
||||
//
|
||||
// lengthToLabel
|
||||
//
|
||||
lengthToLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
lengthToLabel.AutoSize = true;
|
||||
lengthToLabel.Name = "lengthToLabel";
|
||||
lengthToLabel.Text = "to";
|
||||
//
|
||||
// maxLengthBox
|
||||
//
|
||||
maxLengthBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
maxLengthBox.Name = "maxLengthBox";
|
||||
maxLengthBox.TabIndex = 6;
|
||||
//
|
||||
// widthLabel
|
||||
//
|
||||
widthLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
widthLabel.AutoSize = true;
|
||||
widthLabel.Name = "widthLabel";
|
||||
widthLabel.Text = "Shorter side (in):";
|
||||
//
|
||||
// minWidthBox
|
||||
//
|
||||
minWidthBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
minWidthBox.Name = "minWidthBox";
|
||||
minWidthBox.TabIndex = 7;
|
||||
//
|
||||
// widthToLabel
|
||||
//
|
||||
widthToLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
widthToLabel.AutoSize = true;
|
||||
widthToLabel.Name = "widthToLabel";
|
||||
widthToLabel.Text = "to";
|
||||
//
|
||||
// maxWidthBox
|
||||
//
|
||||
maxWidthBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
maxWidthBox.Name = "maxWidthBox";
|
||||
maxWidthBox.TabIndex = 8;
|
||||
//
|
||||
// holesLabel
|
||||
//
|
||||
holesLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
holesLabel.AutoSize = true;
|
||||
holesLabel.Name = "holesLabel";
|
||||
holesLabel.Text = "Holes:";
|
||||
//
|
||||
// minHolesBox
|
||||
//
|
||||
minHolesBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
minHolesBox.Name = "minHolesBox";
|
||||
minHolesBox.TabIndex = 9;
|
||||
//
|
||||
// holesToLabel
|
||||
//
|
||||
holesToLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
holesToLabel.AutoSize = true;
|
||||
holesToLabel.Name = "holesToLabel";
|
||||
holesToLabel.Text = "to";
|
||||
//
|
||||
// maxHolesBox
|
||||
//
|
||||
maxHolesBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
maxHolesBox.Name = "maxHolesBox";
|
||||
maxHolesBox.TabIndex = 10;
|
||||
//
|
||||
// savedLabel
|
||||
//
|
||||
savedLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
savedLabel.AutoSize = true;
|
||||
savedLabel.Name = "savedLabel";
|
||||
savedLabel.Text = "Saved:";
|
||||
//
|
||||
// savedFromPicker
|
||||
//
|
||||
savedFromPicker.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
savedFromPicker.Format = System.Windows.Forms.DateTimePickerFormat.Short;
|
||||
savedFromPicker.Name = "savedFromPicker";
|
||||
savedFromPicker.ShowCheckBox = true;
|
||||
savedFromPicker.TabIndex = 11;
|
||||
//
|
||||
// savedToLabel
|
||||
//
|
||||
savedToLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
|
||||
savedToLabel.AutoSize = true;
|
||||
savedToLabel.Name = "savedToLabel";
|
||||
savedToLabel.Text = "to";
|
||||
//
|
||||
// savedToPicker
|
||||
//
|
||||
savedToPicker.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
savedToPicker.Format = System.Windows.Forms.DateTimePickerFormat.Short;
|
||||
savedToPicker.Name = "savedToPicker";
|
||||
savedToPicker.ShowCheckBox = true;
|
||||
savedToPicker.TabIndex = 12;
|
||||
//
|
||||
// noteLabel
|
||||
//
|
||||
noteLabel.AutoSize = true;
|
||||
noteLabel.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
noteLabel.Margin = new System.Windows.Forms.Padding(3, 8, 3, 0);
|
||||
noteLabel.MaximumSize = new System.Drawing.Size(440, 0);
|
||||
noteLabel.Name = "noteLabel";
|
||||
noteLabel.Text = "Leave a box blank for no limit; every limit includes its value. Thickness takes "
|
||||
+ "gauges and fractions (16 ga, 3/16). Sizes are the bounding box of the cut "
|
||||
+ "geometry and holes are closed cutouts, both measured by the server, so a size "
|
||||
+ "or hole limit leaves out drawings it has not measured yet.";
|
||||
//
|
||||
// errorLabel
|
||||
//
|
||||
errorLabel.AutoSize = true;
|
||||
errorLabel.ForeColor = System.Drawing.Color.Firebrick;
|
||||
errorLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0);
|
||||
errorLabel.MaximumSize = new System.Drawing.Size(440, 0);
|
||||
errorLabel.Name = "errorLabel";
|
||||
//
|
||||
// buttonPanel
|
||||
//
|
||||
buttonPanel.AutoSize = true;
|
||||
buttonPanel.Controls.Add(cancelButton);
|
||||
buttonPanel.Controls.Add(okButton);
|
||||
buttonPanel.Controls.Add(clearButton);
|
||||
buttonPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
|
||||
buttonPanel.Margin = new System.Windows.Forms.Padding(0, 8, 0, 0);
|
||||
buttonPanel.Name = "buttonPanel";
|
||||
buttonPanel.TabIndex = 13;
|
||||
//
|
||||
// cancelButton
|
||||
//
|
||||
cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
cancelButton.Name = "cancelButton";
|
||||
cancelButton.Size = new System.Drawing.Size(80, 27);
|
||||
cancelButton.TabIndex = 2;
|
||||
cancelButton.Text = "Cancel";
|
||||
cancelButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// okButton
|
||||
//
|
||||
okButton.Name = "okButton";
|
||||
okButton.Size = new System.Drawing.Size(80, 27);
|
||||
okButton.TabIndex = 1;
|
||||
okButton.Text = "OK";
|
||||
okButton.UseVisualStyleBackColor = true;
|
||||
okButton.Click += OkButton_Click;
|
||||
//
|
||||
// clearButton
|
||||
//
|
||||
clearButton.Margin = new System.Windows.Forms.Padding(3, 3, 60, 3);
|
||||
clearButton.Name = "clearButton";
|
||||
clearButton.Size = new System.Drawing.Size(80, 27);
|
||||
clearButton.TabIndex = 0;
|
||||
clearButton.Text = "Clear";
|
||||
clearButton.UseVisualStyleBackColor = true;
|
||||
clearButton.Click += ClearButton_Click;
|
||||
//
|
||||
// DrawingLibraryFilterForm
|
||||
//
|
||||
AcceptButton = okButton;
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
AutoSize = true;
|
||||
AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
|
||||
CancelButton = cancelButton;
|
||||
Controls.Add(rootTable);
|
||||
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
Name = "DrawingLibraryFilterForm";
|
||||
Padding = new System.Windows.Forms.Padding(12);
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
Text = "Filter Drawing Library";
|
||||
rootTable.ResumeLayout(false);
|
||||
rootTable.PerformLayout();
|
||||
buttonPanel.ResumeLayout(false);
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.TableLayoutPanel rootTable;
|
||||
private System.Windows.Forms.Label customerLabel;
|
||||
private System.Windows.Forms.ComboBox customerBox;
|
||||
private System.Windows.Forms.Label thicknessLabel;
|
||||
private OpenNest.Controls.ThicknessComboBox minThicknessBox;
|
||||
private System.Windows.Forms.Label thicknessToLabel;
|
||||
private OpenNest.Controls.ThicknessComboBox maxThicknessBox;
|
||||
private System.Windows.Forms.CheckBox blankThicknessCheckBox;
|
||||
private System.Windows.Forms.Label lengthLabel;
|
||||
private System.Windows.Forms.TextBox minLengthBox;
|
||||
private System.Windows.Forms.Label lengthToLabel;
|
||||
private System.Windows.Forms.TextBox maxLengthBox;
|
||||
private System.Windows.Forms.Label widthLabel;
|
||||
private System.Windows.Forms.TextBox minWidthBox;
|
||||
private System.Windows.Forms.Label widthToLabel;
|
||||
private System.Windows.Forms.TextBox maxWidthBox;
|
||||
private System.Windows.Forms.Label holesLabel;
|
||||
private System.Windows.Forms.TextBox minHolesBox;
|
||||
private System.Windows.Forms.Label holesToLabel;
|
||||
private System.Windows.Forms.TextBox maxHolesBox;
|
||||
private System.Windows.Forms.Label savedLabel;
|
||||
private System.Windows.Forms.DateTimePicker savedFromPicker;
|
||||
private System.Windows.Forms.Label savedToLabel;
|
||||
private System.Windows.Forms.DateTimePicker savedToPicker;
|
||||
private System.Windows.Forms.Label noteLabel;
|
||||
private System.Windows.Forms.Label errorLabel;
|
||||
private System.Windows.Forms.FlowLayoutPanel buttonPanel;
|
||||
private System.Windows.Forms.Button cancelButton;
|
||||
private System.Windows.Forms.Button okButton;
|
||||
private System.Windows.Forms.Button clearButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// The drawing library's Filter dialog: a customer and optional, inclusive ranges of
|
||||
/// thickness, size (longer and shorter side), holes and saved date. Thickness and sizes are
|
||||
/// inches; a box left blank sets no limit. OK is refused, with the reason shown, until every
|
||||
/// box reads and each range runs low to high. Clear empties every box.
|
||||
/// </summary>
|
||||
public partial class DrawingLibraryFilterForm : Form
|
||||
{
|
||||
/// <summary>The filter read when OK was pressed.</summary>
|
||||
public DrawingLibraryFilter Filter { get; private set; }
|
||||
|
||||
public DrawingLibraryFilterForm(DrawingLibraryFilter filter, IEnumerable<string> customers = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
Filter = filter ?? DrawingLibraryFilter.None;
|
||||
customerBox.Items.AddRange((customers ?? Array.Empty<string>()).Cast<object>().ToArray());
|
||||
ShowFilter(Filter);
|
||||
}
|
||||
|
||||
/// <summary>Reads every box; false with the first problem when one does not read or a range is reversed.</summary>
|
||||
internal bool TryReadFilter(out DrawingLibraryFilter filter, out string error)
|
||||
{
|
||||
filter = null;
|
||||
if (!TryThickness(minThicknessBox, "lowest thickness", out var minThickness, out error)
|
||||
|| !TryThickness(maxThicknessBox, "highest thickness", out var maxThickness, out error)
|
||||
|| !TryLength(minLengthBox.Text, "lowest longer side", out var minLength, out error)
|
||||
|| !TryLength(maxLengthBox.Text, "highest longer side", out var maxLength, out error)
|
||||
|| !TryLength(minWidthBox.Text, "lowest shorter side", out var minWidth, out error)
|
||||
|| !TryLength(maxWidthBox.Text, "highest shorter side", out var maxWidth, out error)
|
||||
|| !TryCount(minHolesBox.Text, "fewest holes", out var minHoles, out error)
|
||||
|| !TryCount(maxHolesBox.Text, "most holes", out var maxHoles, out error))
|
||||
return false;
|
||||
|
||||
var read = new DrawingLibraryFilter
|
||||
{
|
||||
Customer = customerBox.Text.Trim(),
|
||||
MinThickness = minThickness,
|
||||
MaxThickness = maxThickness,
|
||||
IncludeBlankThickness = blankThicknessCheckBox.Checked,
|
||||
MinLength = minLength,
|
||||
MaxLength = maxLength,
|
||||
MinWidth = minWidth,
|
||||
MaxWidth = maxWidth,
|
||||
MinHoles = minHoles,
|
||||
MaxHoles = maxHoles,
|
||||
SavedFrom = savedFromPicker.Checked ? DateOnly.FromDateTime(savedFromPicker.Value) : null,
|
||||
SavedTo = savedToPicker.Checked ? DateOnly.FromDateTime(savedToPicker.Value) : null,
|
||||
};
|
||||
error = read.GetValidationError();
|
||||
if (error != null)
|
||||
return false;
|
||||
filter = read;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a length in inches: a number or a fraction ("3/16", "1-1/2"), with an optional
|
||||
/// inch mark. Blank is no limit. Gauges are thicknesses, not lengths, so they are refused.
|
||||
/// </summary>
|
||||
internal static bool TryParseLength(string text, out double? value)
|
||||
{
|
||||
value = null;
|
||||
var trimmed = (text ?? "").Trim().TrimEnd('"').Trim();
|
||||
if (trimmed.EndsWith("in", StringComparison.OrdinalIgnoreCase))
|
||||
trimmed = trimmed[..^2].Trim();
|
||||
if (trimmed.Length == 0)
|
||||
return true;
|
||||
if (trimmed.Contains("ga", StringComparison.OrdinalIgnoreCase) || trimmed.Contains('#'))
|
||||
return false;
|
||||
if (!StandardThicknesses.TryParse(trimmed, Units.Inches, out var parsed))
|
||||
return false;
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ShowFilter(DrawingLibraryFilter filter)
|
||||
{
|
||||
customerBox.Text = filter.Customer;
|
||||
minThicknessBox.Text = filter.MinThickness is { } minThickness ? StandardThicknesses.Format(minThickness, Units.Inches) : "";
|
||||
maxThicknessBox.Text = filter.MaxThickness is { } maxThickness ? StandardThicknesses.Format(maxThickness, Units.Inches) : "";
|
||||
blankThicknessCheckBox.Checked = filter.IncludeBlankThickness;
|
||||
minLengthBox.Text = Number(filter.MinLength);
|
||||
maxLengthBox.Text = Number(filter.MaxLength);
|
||||
minWidthBox.Text = Number(filter.MinWidth);
|
||||
maxWidthBox.Text = Number(filter.MaxWidth);
|
||||
minHolesBox.Text = filter.MinHoles?.ToString(CultureInfo.CurrentCulture) ?? "";
|
||||
maxHolesBox.Text = filter.MaxHoles?.ToString(CultureInfo.CurrentCulture) ?? "";
|
||||
ShowDate(savedFromPicker, filter.SavedFrom);
|
||||
ShowDate(savedToPicker, filter.SavedTo);
|
||||
errorLabel.Text = "";
|
||||
}
|
||||
|
||||
private static void ShowDate(DateTimePicker picker, DateOnly? date)
|
||||
{
|
||||
picker.Value = (date ?? DateOnly.FromDateTime(DateTime.Today)).ToDateTime(TimeOnly.MinValue);
|
||||
picker.Checked = date != null;
|
||||
}
|
||||
|
||||
private static string Number(double? value) =>
|
||||
value?.ToString("0.####", CultureInfo.CurrentCulture) ?? "";
|
||||
|
||||
private static bool TryThickness(Controls.ThicknessComboBox box, string name, out double? value, out string error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
if (box.Text.Trim().Length == 0)
|
||||
return true;
|
||||
if (box.TryGetValue(out var parsed))
|
||||
{
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
error = $"The {name} must be a number, a fraction such as 3/16 or a gauge such as 16 ga.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryLength(string text, string name, out double? value, out string error)
|
||||
{
|
||||
error = TryParseLength(text, out value) ? null : $"The {name} must be inches: a number or a fraction such as 3-1/2.";
|
||||
return error == null;
|
||||
}
|
||||
|
||||
private static bool TryCount(string text, string name, out int? value, out string error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
var trimmed = (text ?? "").Trim();
|
||||
if (trimmed.Length == 0)
|
||||
return true;
|
||||
if (int.TryParse(trimmed, NumberStyles.None, CultureInfo.CurrentCulture, out var parsed))
|
||||
{
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
error = $"The {name} must be a whole number, zero or greater.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private void OkButton_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!TryReadFilter(out var filter, out var error))
|
||||
{
|
||||
errorLabel.Text = error;
|
||||
return;
|
||||
}
|
||||
Filter = filter;
|
||||
DialogResult = DialogResult.OK;
|
||||
}
|
||||
|
||||
private void ClearButton_Click(object sender, EventArgs e) => ShowFilter(DrawingLibraryFilter.None);
|
||||
}
|
||||
}
|
||||
+53
-5
@@ -304,7 +304,8 @@ first upload), `revision`, `name`, `customer`, `materialName`, `materialGrade`,
|
||||
`thickness` (stored rounded to 4 decimals), `units` (`inches` | `millimeters`),
|
||||
`cutType` (`laser` | `waterjet` | `plasma`), `notes`, `settings`, `archived`,
|
||||
`savedBy`, and the server-computed `packageSha256` (lower-case hex), `packageSize`
|
||||
and `savedAt`.
|
||||
and `savedAt`. Queries also return `geometry`, the package's measured size
|
||||
([below](#measured-sizes)), or null until it is measured; uploads ignore it.
|
||||
|
||||
`settings` holds the drawing's nesting settings, applied when it is added to a
|
||||
nest: `cutFirst` (sequence its parts first, for example test squares), `nestPriority`
|
||||
@@ -320,7 +321,7 @@ each nest.
|
||||
| Method | Path | Body | Response |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/drawings` | multipart: `metadata` (JSON record with a nonempty `id`) + `package` | Revision 1; 409 with the current record when the id exists |
|
||||
| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&cutFirst=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }` |
|
||||
| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&cutFirst=&includeArchived=&sort=&order=&offset=&limit=` plus the ranges below | — | `{ items, total, offset, limit }` |
|
||||
| GET | `/api/drawings/{id}` / `/{id}/file` | — | Current record / package bytes (archived drawings included), or 404 |
|
||||
| GET | `/api/drawings/{id}/revisions?offset=0&limit=100` | — | Revision page, newest first; limit 1–200 |
|
||||
| GET | `/api/drawings/{id}/revisions/{revision}/file` | — | Exact immutable package bytes |
|
||||
@@ -356,8 +357,19 @@ each nest.
|
||||
every `materialName`, `materialGrade` or `thickness` filter (for example a
|
||||
test square added to every nest). `sort` is one of `savedAt`
|
||||
(default), `name`, `customer`, `materialName`, `materialGrade`, `thickness`,
|
||||
`cutType`, `revision`; ties break by `id`. `cutFirst=true|false` keeps only
|
||||
drawings with that setting. Paging, `order` and the refusal of
|
||||
`cutType`, `revision`, `length`, `holes` (unmeasured drawings sort as
|
||||
smallest); ties break by `id`. `cutFirst=true|false` keeps only
|
||||
drawings with that setting.
|
||||
- Ranges (the desktop's Filter dialog), each optional and inclusive:
|
||||
`minThickness`/`maxThickness` in inches (a millimeter drawing's thickness is
|
||||
converted); a drawing saved without a thickness still matches unless
|
||||
`includeBlankThickness=false`, which also leaves such drawings out with no
|
||||
range. `minLength`/`maxLength` (longer side), `minWidth`/`maxWidth` (shorter
|
||||
side) in inches and `minHoles`/`maxHoles` match only measured drawings.
|
||||
`savedFrom`/`savedTo` (`yyyy-MM-dd`) compare the server's local save date.
|
||||
A reversed range, a negative or nonfinite number, a fractional hole count or
|
||||
another date format is a 400. An older server refuses these parameters as
|
||||
unknown. Paging, `order` and the refusal of
|
||||
unknown, repeated or out-of-range parameters follow the nest query.
|
||||
- An older server answers 404 on these routes; the client reports that the
|
||||
server must be updated and never falls back to another store.
|
||||
@@ -375,6 +387,30 @@ to open a v5 database, so rolling back after the upgrade needs the pre-upgrade b
|
||||
`tar` backup includes the drawing tables, but `nest_manifest.py` and
|
||||
`restore_check` do not verify drawings yet.
|
||||
|
||||
### Measured sizes
|
||||
|
||||
The server never parses packages, so the server worker
|
||||
([server nesting](server-nesting.md)) measures them beside its nest jobs: it asks
|
||||
for packages of current drawings (archived included) without a summary at the
|
||||
current measurement version, downloads each, reads it with
|
||||
`DrawingPackage.Read` and stores an `OpenNest.Data.DrawingGeometrySummary`.
|
||||
The summary is the material's bounding box (marks excluded) as `length` (longer
|
||||
side) and `width` (shorter side) in inches, the net `area` in square inches and
|
||||
`holes`, the closed contours inside the largest one. A package that cannot be
|
||||
read, has no closed cut geometry or takes over 30 s to measure is stored with
|
||||
an `error` and never retried at that version; a server or network failure stores
|
||||
nothing. Summaries are keyed by package hash (schema v19,
|
||||
`drawing_package_geometry`), so identical geometry is measured once and a
|
||||
details-only revision keeps its summary. Raising
|
||||
`DrawingGeometrySummary.CurrentVersion` re-measures every package. Without a
|
||||
running worker nothing is measured, and size or hole limits match nothing.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/drawing-packages/unmeasured?version=&limit=` | — | Up to `limit` (1–500) package hashes, in hash order |
|
||||
| GET | `/api/drawing-packages/{sha256}` | — | Package bytes, or 404 |
|
||||
| PUT | `/api/drawing-packages/{sha256}/geometry` | summary JSON | 204; 404 for an unknown package; 400 when invalid. An older `version` than the stored one is ignored |
|
||||
|
||||
### Add from Drawing Library (desktop)
|
||||
|
||||
**Nest > Add from Drawing Library...** and the **Library** button above the
|
||||
@@ -415,7 +451,19 @@ is edited in place (0 to 99,999; one click starts editing, a bad number keeps
|
||||
the editor open with an error) and applies to the nest at once, like an add;
|
||||
the main window's drawing list refreshes when the dialog closes.
|
||||
|
||||
The browser's **Cut first** column shows the setting.
|
||||
The browser's **Cut first** column shows the setting; **Size (in)** (longer x
|
||||
shorter side) and **Holes** show the measured summary, blank until it is measured
|
||||
and "?" when it could not be.
|
||||
|
||||
**Filter...** opens a dialog (`DrawingLibraryFilterForm`) for a customer and
|
||||
inclusive ranges of thickness, longer side, shorter side, holes and saved date,
|
||||
sent as the query ranges above together with the search box and the nest filter.
|
||||
Thickness takes gauges and fractions (16 ga, 3/16) and sizes take fractions, all
|
||||
in inches; a blank box sets no limit, a box that does not read or a reversed
|
||||
range keeps the dialog open with the reason, and **Clear** empties every box.
|
||||
While a filter is set the button reads **Filter (on)...** and the line under the
|
||||
search box describes it. The customer list offers the customers on the current
|
||||
page; the customer matches exactly, like the query's `customer`.
|
||||
|
||||
**File > Drawing Library...** (Database mode only, like Drawing Sets) opens the
|
||||
drawing sets window on its **Library** tab ([below](#drawing-sets-desktop)),
|
||||
|
||||
@@ -4,6 +4,9 @@ In Database mode, Auto Nest can send the job to the nest server instead of nesti
|
||||
workstation. A worker container solves queued jobs one at a time under its own CPU and memory
|
||||
limits, and the workstation reviews and keeps the result exactly as it would a local run.
|
||||
Local nesting stays the default and the fallback when the server cannot take a job.
|
||||
Between and beside jobs the worker also measures library drawings for the library's size and
|
||||
hole filters (see [measured sizes](nest-storage.md#measured-sizes)); it polls every 60 seconds
|
||||
when none are waiting.
|
||||
|
||||
## Desktop
|
||||
|
||||
|
||||
Reference in new issue
Block a user