From 6e40c2e6ed3c367cf063b2fa0ea322dad1343e67 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 26 Oct 2026 08:42:36 -0400 Subject: [PATCH] feat(library): redesign Edit Drawing with drop-downs, color modes and a rotation dial Typing customer, material, grade and thickness by hand let near-duplicate spellings into the library, which then split drawings that should match. The fields are now drop-downs of the values the library and the machine material catalog already use, while still accepting new values; thicknesses already used for the material are listed ahead of the standard gauges. A library color could be set but never cleared back to the color scheme's, so Automatic and Reset are added. The rotation start/end/step boxes gave no sense of the range, so a dial shades it, ticks the angles the engines try (via RotationPolicy) and lets either end be dragged. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/DrawingLibraryChoices.cs | 83 +++ OpenNest.Data/DrawingLibraryEditSession.cs | 28 + .../Data/DrawingLibraryChoicesTests.cs | 115 ++++ .../Controls/RotationRangeDialTests.cs | 108 ++++ .../Forms/DrawingLibraryFormTests.cs | 151 ++++- OpenNest/Controls/RotationRangeDial.cs | 457 ++++++++++++++++ OpenNest/Controls/ThicknessComboBox.cs | 30 +- .../Forms/EditLibraryDrawingForm.Designer.cs | 514 ++++++++++++------ OpenNest/Forms/EditLibraryDrawingForm.cs | 195 ++++++- docs/nest-storage.md | 20 + 10 files changed, 1524 insertions(+), 177 deletions(-) create mode 100644 OpenNest.Data/DrawingLibraryChoices.cs create mode 100644 OpenNest.Tests/Data/DrawingLibraryChoicesTests.cs create mode 100644 OpenNest.WinForms.Tests/Controls/RotationRangeDialTests.cs create mode 100644 OpenNest/Controls/RotationRangeDial.cs 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