From 9d6c10c563ac83d016fba3d8de6b7e544ba74fd3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 17 Oct 2026 20:54:24 -0400 Subject: [PATCH] feat(ui): add Parts tab listing pass/fail status for every part Lists every part in the cutting inspection panel's scope, including ones with no finding, so an all-clear nest doesn't just look like an empty grid. Selecting a part jumps to its first issue or zooms to it when clear. Co-Authored-By: Claude Sonnet 5 --- .../Forms/CuttingInspectionPanelTests.cs | 32 ++++ .../Forms/CuttingValidationPanelTests.cs | 22 +++ OpenNest/Forms/CuttingInspectionPanel.cs | 140 +++++++++++++++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs index 65ef4681..55eceeae 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs @@ -215,6 +215,38 @@ public class CuttingInspectionPanelTests Assert.Null(view.ValidationOverlay.Finding); }); + [Fact] + public void PartsTabListsEveryPartAndFlagsOnlyThoseWithIssues() => RunSta(() => + { + using var editor = Editor(); + var plate = editor.Nest.Plates[0]; + plate.Parts.Add(Square("overlapping", 5)); + editor.Show(); + editor.PlateManager.LoadAt(0); + editor.OpenCuttingInspection(); + var panel = editor.CuttingInspection!; + PumpUntil(() => panel.Proposal != null && !panel.IsPlanning); + var issues = Field(panel, "warningGrid"); + var parts = Field(panel, "partsGrid"); + Assert.Equal(plate.Parts.Count, parts.Rows.Count); + var flaggedPartNumbers = issues.Rows.Cast() + .SelectMany(r => r.Tag is OpenNest.Engine.CuttingPlanning.CuttingPlanDiagnosticRow d + ? new[] { d.PartNumber, d.OtherPartNumber }.OfType() : []) + .ToHashSet(); + Assert.NotEmpty(flaggedPartNumbers); + foreach (DataGridViewRow row in parts.Rows) + { + var partNumber = (int)row.Cells[1].Value!; + var status = (string)row.Cells[2].Value!; + Assert.Equal(!flaggedPartNumbers.Contains(partNumber), status == "Passed"); + } + var flaggedRow = parts.Rows.Cast().First(r => flaggedPartNumbers.Contains((int)r.Cells[1].Value!)); + parts.ClearSelection(); + flaggedRow.Selected = true; // Cross-selects the matching Issues row and its marker. + Assert.NotNull(editor.PlateView.ValidationOverlay.Finding?.Overlap); + panel.RequestClose(); + }); + [Fact] public void PlannedCutOffsKeepTheCutOffLineStyleOnTheProposal() => RunSta(() => { diff --git a/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs index deb959ac..9bfdbab1 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs @@ -70,6 +70,28 @@ public class CuttingValidationPanelTests Assert.Equal(programs, editor.Nest.Plates.SelectMany(p => p.Parts).Select(p => p.Program)); }); + [Fact] + public void PartsTabCoversWholeNestAndSelectionCrossesToItsIssue() => RunSta(() => + { + using var editor = RapidEditor(); + editor.Show(); + editor.OpenValidationInspection(); + var panel = editor.CuttingInspection!; + Pump(() => panel.ValidationReport != null && !panel.IsPlanning); + var issues = Field(panel, "warningGrid"); + var parts = Field(panel, "partsGrid"); + Assert.Equal(editor.Nest.Plates.Sum(p => p.Parts.Count), parts.Rows.Count); + var flaggedRow = parts.Rows.Cast() + .Single(r => (int)r.Cells[0].Value! == 2 && (int)r.Cells[1].Value! == 3); + Assert.NotEqual("Passed", (string)flaggedRow.Cells[2].Value!); + parts.ClearSelection(); + flaggedRow.Selected = true; // Real SelectionChanged precedes CurrentCell assignment. + var selected = Assert.Single(issues.SelectedRows.Cast()); + Assert.True(selected.Tag is PostVerificationFinding + { Kind: PostVerificationKind.RapidCrossing, PlateNumber: 2, PartNumber: 3 }); + panel.RequestClose(); + }); + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs index a9ded4e3..54bd4ffc 100644 --- a/OpenNest/Forms/CuttingInspectionPanel.cs +++ b/OpenNest/Forms/CuttingInspectionPanel.cs @@ -120,6 +120,19 @@ internal sealed class CuttingInspectionPanel : UserControl RowHeadersVisible = false, AutoGenerateColumns = false }); + // Every part in scope, including ones with no finding, so "no rows" never reads as "all clear". + private readonly DataGridView partsGrid = GridStyle.Apply(new DataGridView + { + Dock = DockStyle.Fill, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + MultiSelect = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + RowHeadersVisible = false, + AutoGenerateColumns = false + }); + private readonly TabControl resultTabs = new() { Dock = DockStyle.Fill }; private ValidationInspectionScope scope; private bool closed; private int generation; @@ -181,7 +194,12 @@ internal sealed class CuttingInspectionPanel : UserControl layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.Controls.Add(statusBanner, 0, 0); layout.Controls.Add(header, 0, 1); - layout.Controls.Add(warningGrid, 0, 2); + var issuesTab = new TabPage("Issues"); + issuesTab.Controls.Add(warningGrid); + var partsTab = new TabPage("Parts"); + partsTab.Controls.Add(partsGrid); + resultTabs.TabPages.AddRange([issuesTab, partsTab]); + layout.Controls.Add(resultTabs, 0, 2); Controls.Add(layout); foreach (var name in new[] { "Plate", "Part", "With", "Issue" }) warningGrid.Columns.Add(new DataGridViewTextBoxColumn @@ -191,6 +209,14 @@ internal sealed class CuttingInspectionPanel : UserControl SortMode = DataGridViewColumnSortMode.NotSortable, AutoSizeMode = name == "Issue" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None }); + foreach (var name in new[] { "Plate", "Part", "Status" }) + partsGrid.Columns.Add(new DataGridViewTextBoxColumn + { + HeaderText = name, + Width = 48, + SortMode = DataGridViewColumnSortMode.NotSortable, + AutoSizeMode = name == "Status" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None + }); Padding = new Padding(10); planButton.Click += (_, _) => StartCurrentMode(); cancelButton.Click += (_, _) => RequestClose(); @@ -204,6 +230,7 @@ internal sealed class CuttingInspectionPanel : UserControl previousButton.Click += (_, _) => ShowPlate(selectedPlate - 1); nextButton.Click += (_, _) => ShowPlate(selectedPlate + 1); warningGrid.SelectionChanged += (_, _) => NavigateFinding(); + partsGrid.SelectionChanged += (_, _) => NavigatePart(); ShowSettings(); } @@ -440,6 +467,7 @@ internal sealed class CuttingInspectionPanel : UserControl warningGrid.Rows[index].Tag = finding; } warningGrid.ClearSelection(); + PopulatePartsGrid(); } finally { populatingWarnings = false; } } @@ -654,6 +682,7 @@ internal sealed class CuttingInspectionPanel : UserControl warningGrid.Rows[index].Tag = row; } warningGrid.ClearSelection(); + PopulatePartsGrid(); } finally { populatingWarnings = false; } ShowPlanStatus(); @@ -762,7 +791,7 @@ internal sealed class CuttingInspectionPanel : UserControl previewLabel.Text = "No current preview."; SetStatus(Tone.Neutral, string.Empty); populatingWarnings = true; - try { warningGrid.Rows.Clear(); } + try { warningGrid.Rows.Clear(); partsGrid.Rows.Clear(); } finally { populatingWarnings = false; } } @@ -893,6 +922,113 @@ internal sealed class CuttingInspectionPanel : UserControl SetDetail("Zoomed to the affected part; this issue has no exact location."); } + /// + /// Every part in scope, so a part with nothing wrong still shows as Passed instead of + /// simply not appearing. Validate always covers the whole nest (matching the Issues tab); + /// Plan covers only the plates this instance planned. + /// + private void PopulatePartsGrid() + { + partsGrid.Rows.Clear(); + if (Mode == CuttingInspectionMode.Validate) + { + var all = editor.Nest.Plates; + for (var i = 0; i < all.Count; i++) + AddPartRows(i + 1, all[i].Parts.Count); + } + else + for (var i = 0; i < plates.Length; i++) + AddPartRows(plateNumbers[i], plates[i].Parts.Count); + } + + private void AddPartRows(int plateNumber, int partCount) + { + for (var partIndex = 0; partIndex < partCount; partIndex++) + { + var partNumber = partIndex + 1; + var tone = Tone.Good; + var matches = 0; + foreach (DataGridViewRow row in warningGrid.Rows) + { + if (!Refers(row.Tag, plateNumber, partNumber)) continue; + matches++; + if (KindOf(row.Tag) == PostVerificationKind.Incomplete) tone = Tone.Bad; + else if (tone != Tone.Bad) tone = Tone.Warning; + } + var status = tone switch + { + Tone.Bad => "Not checked", + Tone.Warning => Count(matches, "issue"), + _ => "Passed", + }; + var addedRow = partsGrid.Rows[partsGrid.Rows.Add(plateNumber, partNumber, status)]; + addedRow.Tag = (plateNumber, partNumber); + addedRow.DefaultCellStyle.ForeColor = StatusInk(tone); + } + } + + private static Color StatusInk(Tone tone) => tone switch + { + Tone.Good => Color.FromArgb(28, 88, 28), + Tone.Warning => Color.FromArgb(118, 78, 0), + Tone.Bad => Color.FromArgb(132, 20, 20), + _ => SystemColors.ControlText, + }; + + /// Whether a warning/issue row's tag names this plate and part, either as the subject or the other part. + private static bool Refers(object tag, int plateNumber, int partNumber) => tag switch + { + PostVerificationFinding f => f.PlateNumber == plateNumber && (f.PartNumber == partNumber || f.OtherPartNumber == partNumber), + CuttingPlanDiagnosticRow r => r.PlateNumber == plateNumber && (r.PartNumber == partNumber || r.OtherPartNumber == partNumber), + _ => false, + }; + + private static PostVerificationKind? KindOf(object tag) => tag switch + { + PostVerificationFinding f => f.Kind, + CuttingPlanDiagnosticRow { Witness: { } witness } => witness.Kind, + _ => null, + }; + + /// Selecting a part jumps to its first issue (reusing that marker) or just zooms to it when clear. + private void NavigatePart() + { + if (closed || closeRequested || pendingMode != null || populatingWarnings || IsPlanning || editor.IsDisposed + || partsGrid.SelectedRows.Count == 0 || partsGrid.SelectedRows[0].Tag is not ValueTuple target) + return; + var (plateNumber, partNumber) = target; + foreach (DataGridViewRow row in warningGrid.Rows) + if (Refers(row.Tag, plateNumber, partNumber)) + { + warningGrid.ClearSelection(); + row.Selected = true; + warningGrid.CurrentCell = row.Cells[0]; + return; // warningGrid.SelectionChanged reuses NavigateFinding/NavigateValidationFinding. + } + view.ClearValidationFinding(); + if (Mode == CuttingInspectionMode.Validate) + { + if (plateNumber < 1 || plateNumber > editor.Nest.Plates.Count) return; + if (editor.PlateManager.CurrentIndex != plateNumber - 1) + editor.PlateManager.LoadAt(plateNumber - 1); + if (partNumber > 0 && partNumber <= view.Plate.Parts.Count + && view.Plate.Parts[partNumber - 1]?.BoundingBox is { } box + && double.IsFinite(box.X) && double.IsFinite(box.Y) && double.IsFinite(box.Length) && double.IsFinite(box.Width)) + { + var pad = System.Math.Max(0.5, System.Math.Max(box.Length, box.Width) * 0.12); + view.ZoomToArea(box.X - pad, box.Y - pad, box.Length + 2 * pad, box.Width + 2 * pad); + } + else + view.ZoomToFit(); + SetDetail("No issue found for this part."); + } + else + { + var index = Array.IndexOf(plateNumbers, plateNumber); + if (index >= 0) ShowPlate(index); + } + } + internal CuttingCommitResult CommitResult { get; private set; } private void Apply()