diff --git a/OpenNest.Data/DrawingLibraryChoices.cs b/OpenNest.Data/DrawingLibraryChoices.cs
new file mode 100644
index 00000000..d6970f3f
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryChoices.cs
@@ -0,0 +1,83 @@
+namespace OpenNest.Data;
+
+///
+/// The customers, materials, grades and thicknesses an edit offers in its drop-downs: those
+/// already used by library drawings, plus the materials and grades of a material catalog.
+/// Names match by and keep the first spelling seen, so
+/// pass the newest records first. Thicknesses come only from records in the edit's units.
+/// The lists are suggestions; any typed value may still be saved.
+///
+public sealed class DrawingLibraryChoices
+{
+ private readonly List _records;
+ private readonly List _catalog;
+
+ private DrawingLibraryChoices(List records, List catalog, Units units)
+ {
+ _records = records;
+ Units = units;
+ _catalog = catalog;
+ Customers = Distinct(records.Select(r => r.Customer));
+ Materials = Distinct(records.Select(r => r.MaterialName).Concat(catalog.Select(m => m.Name)));
+ }
+
+ public static DrawingLibraryChoices Empty { get; } = new(new(), new(), Units.Inches);
+
+ /// Library drawings, newest first; archived ones are skipped.
+ /// The edit's units; thicknesses in other units are not offered.
+ /// Materials and grades on file, for example the machine configurations'.
+ public static DrawingLibraryChoices From(IEnumerable records, Units units,
+ IEnumerable? catalog = null)
+ {
+ ArgumentNullException.ThrowIfNull(records);
+ return new DrawingLibraryChoices(records.Where(r => r != null && !r.Archived).ToList(),
+ catalog?.Where(m => m != null).ToList() ?? new(), units);
+ }
+
+ public Units Units { get; }
+
+ /// Customers, sorted.
+ public IReadOnlyList Customers { get; }
+
+ /// Material names, sorted.
+ public IReadOnlyList Materials { get; }
+
+ /// The grades used or on file for , sorted.
+ public IReadOnlyList GradesOf(string? material)
+ {
+ var key = SharedListNames.Key(material);
+ if (key.Length == 0)
+ return Array.Empty();
+ return Distinct(_records.Where(r => SharedListNames.Key(r.MaterialName) == key).Select(r => r.MaterialGrade)
+ .Concat(_catalog.Where(m => SharedListNames.Key(m.Name) == key).Select(m => m.Grade)));
+ }
+
+ ///
+ /// The thicknesses library drawings of (and of
+ /// , when one is given) use, thinnest first.
+ ///
+ public IReadOnlyList ThicknessesOf(string? material, string? grade)
+ {
+ var materialKey = SharedListNames.Key(material);
+ if (materialKey.Length == 0)
+ return Array.Empty();
+ var gradeKey = SharedListNames.Key(grade);
+ return _records
+ .Where(r => r.Units == Units && SharedListNames.Key(r.MaterialName) == materialKey
+ && (gradeKey.Length == 0 || SharedListNames.Key(r.MaterialGrade) == gradeKey)
+ && double.IsFinite(r.Thickness) && r.Thickness > 0)
+ .Select(r => DrawingLibraryRules.NormalizeThickness(r.Thickness))
+ .Distinct()
+ .OrderBy(t => t)
+ .ToList();
+ }
+
+ private static IReadOnlyList Distinct(IEnumerable names) =>
+ names
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Select(name => MaterialListRules.Spelling(name))
+ .GroupBy(SharedListNames.Key)
+ .Select(group => group.First())
+ .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+}
diff --git a/OpenNest.Data/DrawingLibraryEditSession.cs b/OpenNest.Data/DrawingLibraryEditSession.cs
index 78532034..2e98628a 100644
--- a/OpenNest.Data/DrawingLibraryEditSession.cs
+++ b/OpenNest.Data/DrawingLibraryEditSession.cs
@@ -99,6 +99,34 @@ public sealed class DrawingLibraryEditSession
return matches.Where(m => m.Id != Listed.Id).ToList();
}
+ ///
+ /// The drop-down choices for this edit: the customers, materials, grades and thicknesses
+ /// of the active library drawings (the newest
+ /// when there are more) and of the listed drawing, plus 's materials.
+ ///
+ public async Task LoadChoicesAsync(IEnumerable? catalog = null,
+ CancellationToken cancellationToken = default)
+ {
+ var records = new List();
+ var offset = 0;
+ while (offset < DrawingLibrarySaveSession.MaxScannedDrawings)
+ {
+ var page = await _library.QueryAsync(new DrawingLibraryQuery
+ {
+ Sort = DrawingSortField.SavedAt,
+ Descending = true,
+ Offset = offset,
+ Limit = DrawingLibraryQuery.MaxLimit,
+ }, cancellationToken);
+ records.AddRange(page.Items);
+ offset += page.Items.Count;
+ if (page.Items.Count == 0 || offset >= page.Total)
+ break;
+ }
+ records.Add(Listed);
+ return DrawingLibraryChoices.From(records, Listed.Units, catalog);
+ }
+
// The record with the edited details and the listed notes and settings, for identity checks.
private DrawingLibraryRecord BuildDetails(DrawingLibraryDetails details) =>
Build(details, Listed.Notes, Listed.Settings ?? new DrawingNestingSettings());
diff --git a/OpenNest.Tests/Data/DrawingLibraryChoicesTests.cs b/OpenNest.Tests/Data/DrawingLibraryChoicesTests.cs
new file mode 100644
index 00000000..ab764653
--- /dev/null
+++ b/OpenNest.Tests/Data/DrawingLibraryChoicesTests.cs
@@ -0,0 +1,115 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class DrawingLibraryChoicesTests
+{
+ private static DrawingLibraryRecord Record(string customer, string material, string grade, double thickness,
+ Units units = Units.Inches, bool archived = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Revision = 1,
+ Name = "Synthetic part",
+ Customer = customer,
+ MaterialName = material,
+ MaterialGrade = grade,
+ Thickness = thickness,
+ Units = units,
+ Archived = archived,
+ };
+
+ [Fact]
+ public void Lists_KeepTheFirstSpellingOfEachName_Sorted_AndSkipBlanksAndArchivedDrawings()
+ {
+ var choices = DrawingLibraryChoices.From(new[]
+ {
+ Record("Synthetic Co", "Synthetic Steel", "Grade A", 0.25),
+ Record("synthetic co", "SYNTHETIC STEEL", "grade a", 0.25),
+ Record("", "Alpha Alloy", "", 0.5),
+ Record("Archived Co", "Archived Metal", "X", 1, archived: true),
+ }, Units.Inches, new[] { new MaterialConfig { Name = "Synthetic Steel", Grade = "Grade C" }, new MaterialConfig { Name = "Beta Brass" } });
+
+ Assert.Equal(new[] { "Synthetic Co" }, choices.Customers);
+ Assert.Equal(new[] { "Alpha Alloy", "Beta Brass", "Synthetic Steel" }, choices.Materials);
+ Assert.Equal(new[] { "Grade A", "Grade C" }, choices.GradesOf(" synthetic steel "));
+ Assert.Empty(choices.GradesOf(""));
+ }
+
+ [Fact]
+ public void Thicknesses_AreTheMaterialsInTheEditsUnits_NarrowedByAGivenGrade()
+ {
+ var choices = DrawingLibraryChoices.From(new[]
+ {
+ Record("", "Synthetic Steel", "Grade A", 0.25),
+ Record("", "Synthetic Steel", "Grade B", 0.1875),
+ Record("", "Synthetic Steel", "Grade B", 0.18750001),
+ Record("", "Synthetic Steel", "Grade A", 6.35, Units.Millimeters),
+ Record("", "Synthetic Steel", "Grade A", 0),
+ Record("", "Other", "Grade A", 1),
+ }, Units.Inches);
+
+ Assert.Equal(new[] { 0.1875, 0.25 }, choices.ThicknessesOf("Synthetic Steel", ""));
+ Assert.Equal(new[] { 0.1875 }, choices.ThicknessesOf("synthetic steel", "GRADE B"));
+ Assert.Empty(choices.ThicknessesOf("", "Grade A"));
+ }
+
+ [Fact]
+ public async Task EditSession_LoadsChoicesFromEveryPage_AndIncludesTheListedDrawing()
+ {
+ var listed = Record("Listed Co", "Listed Metal", "L1", 0.5);
+ var records = Enumerable.Range(0, DrawingLibraryQuery.MaxLimit + 3)
+ .Select(i => Record($"Customer {i:000}", "Synthetic Steel", "Grade A", 0.25))
+ .ToList();
+ var library = new PagedLibrary(records);
+
+ var choices = await new DrawingLibraryEditSession(library, listed, "").LoadChoicesAsync();
+
+ Assert.Equal(2, library.Queries.Count);
+ Assert.All(library.Queries, q => Assert.False(q.IncludeArchived));
+ Assert.Equal(records.Count + 1, choices.Customers.Count);
+ Assert.Contains("Listed Metal", choices.Materials);
+ }
+
+ private sealed class PagedLibrary : IDrawingLibraryStore
+ {
+ private readonly List _records;
+
+ public PagedLibrary(List records) => _records = records;
+
+ public List Queries { get; } = new();
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
+ {
+ Queries.Add(query);
+ return Task.FromResult(new DrawingLibraryPage
+ {
+ Items = _records.Skip(query.Offset).Take(query.Limit).ToArray(),
+ Total = _records.Count,
+ Offset = query.Offset,
+ Limit = query.Limit,
+ });
+ }
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetAsync(Guid id, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UploadAsync(DrawingLibraryRecord record, byte[] package,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
+ int expectedRevision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Controls/RotationRangeDialTests.cs b/OpenNest.WinForms.Tests/Controls/RotationRangeDialTests.cs
new file mode 100644
index 00000000..884da140
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Controls/RotationRangeDialTests.cs
@@ -0,0 +1,108 @@
+using System.Drawing;
+using System.Windows.Forms;
+using OpenNest.Controls;
+
+namespace OpenNest.WinForms.Tests.Controls;
+
+public class RotationRangeDialTests
+{
+ private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30);
+
+ [Fact]
+ public void TriedAngles_FollowTheEnginesRotationPolicy()
+ {
+ Assert.Equal(new[] { 0d, 90d, 180d, 270d }, RotationRangeDial.TriedAngles(10, 20, 0, false));
+ Assert.Equal(7, RotationRangeDial.TriedAngles(0, 90, 15, false).Count);
+ Assert.Equal(14, RotationRangeDial.TriedAngles(0, 90, 15, true).Count);
+ Assert.Equal(new[] { 30d }, RotationRangeDial.TriedAngles(30, 30, 360, false));
+ Assert.Empty(RotationRangeDial.TriedAngles(90, 0, 15, false));
+ }
+
+ [Fact]
+ public void Properties_KeepTheStartAtOrBeforeTheEnd_AndDoNotRaiseTheOperatorEvent()
+ {
+ using var dial = new RotationRangeDial();
+ var raised = 0;
+ dial.AnglesChanged += (_, _) => raised++;
+
+ dial.EndAngle = 90;
+ dial.StartAngle = 120;
+ Assert.Equal((120d, 120d), (dial.StartAngle, dial.EndAngle));
+ dial.EndAngle = 400;
+ dial.StartAngle = -5;
+ Assert.Equal((0d, 360d), (dial.StartAngle, dial.EndAngle));
+ Assert.Equal(0, raised);
+ }
+
+ [Fact]
+ public void Keys_MoveTheActiveHandleWithinTheOtherHandle()
+ {
+ using var dial = new RotationRangeDial { StartAngle = 0, EndAngle = 30 };
+ var raised = 0;
+ dial.AnglesChanged += (_, _) => raised++;
+
+ dial.ActiveHandle = DialHandle.Start;
+ dial.MoveActive(15);
+ dial.MoveActive(15);
+ dial.MoveActive(15);
+ Assert.Equal((30d, 30d), (dial.StartAngle, dial.EndAngle));
+
+ dial.ActiveHandle = DialHandle.End;
+ dial.MoveActive(-1);
+ Assert.Equal(30d, dial.EndAngle);
+ dial.MoveActive(1);
+ Assert.Equal(31d, dial.EndAngle);
+ Assert.Equal(3, raised);
+ }
+
+ [Fact]
+ public void Dragging_MovesTheNearerHandle_AndSweepsTheEndUpToAFullTurn()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ using var form = new Form { ClientSize = new Size(240, 240) };
+ var dial = new DraggableDial { Bounds = new Rectangle(0, 0, 200, 200), StartAngle = 0, EndAngle = 90, StepAngle = 15 };
+ form.Controls.Add(dial);
+ form.Show();
+ var raised = 0;
+ dial.AnglesChanged += (_, _) => raised++;
+
+ // Pressing away from the handles at 180° takes the nearer (end) handle there.
+ dial.Down(At(dial, 180));
+ Assert.Equal((0d, 180d), (dial.StartAngle, dial.EndAngle));
+
+ // The drag follows the pointer past 0°, stopping at a full turn rather than wrapping to 10°.
+ foreach (var angle in new[] { 270d, 300d, 340d, 10d, 40d })
+ dial.Drag(At(dial, angle));
+ dial.Up(At(dial, 40));
+ Assert.Equal((0d, 360d), (dial.StartAngle, dial.EndAngle));
+
+ // Backing off from the full turn moves the end with the pointer again.
+ dial.Down(At(dial, 300));
+ Assert.Equal(300d, dial.EndAngle);
+ dial.Up(At(dial, 300));
+ Assert.True(raised > 0);
+ },
+ TestTimeout,
+ "The rotation dial drag test did not complete.");
+ }
+
+ // A point inside the dial at a small radius, so it is never on a handle.
+ private static Point At(Control dial, double degrees)
+ {
+ var radians = degrees * System.Math.PI / 180;
+ return new Point(
+ (int)System.Math.Round(dial.Width / 2.0 + 30 * System.Math.Cos(radians)),
+ (int)System.Math.Round(dial.Height / 2.0 - 30 * System.Math.Sin(radians)));
+ }
+
+ private sealed class DraggableDial : RotationRangeDial
+ {
+ public void Down(Point point) => OnMouseDown(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
+
+ public void Drag(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.Left, 0, point.X, point.Y, 0));
+
+ public void Up(Point point) => OnMouseUp(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
index d004fef3..7f5229d1 100644
--- a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
@@ -236,7 +236,7 @@ public class DrawingLibraryFormTests
form.View.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
form.View.EditDrawing = edit =>
{
- using var editor = new EditLibraryDrawingForm(edit);
+ using var editor = new EditLibraryDrawingForm(edit) { LoadMaterialCatalog = Array.Empty };
editor.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
editor.Show();
var save = Field