From 0a0da5000664c2ddb89520ca199b8a7c6bdbab96 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Nov 2026 23:17:48 -0500 Subject: [PATCH] feat(ui): show the layout check in the status bar An empty canvas corner gave no sign of whether a check was still running, which matters on large nests. A Layout section beside utilization now follows the automatic check: checking, OK, or findings in red, with the full wording in its tooltip. It has its own label so rechecks never replace the last command's status text. Co-Authored-By: Claude Opus 5.5 --- .../Diagnostics/OverlapReportState.cs | 28 +++++++++++ .../Diagnostics/OverlapSpacingLabelTests.cs | 39 ++++++++++++++++ .../PlateOverlapOverlayTests.cs | 45 ++++++++++++++++++ OpenNest/Controls/OverlapOverlayController.cs | 13 ++++++ OpenNest/Controls/PlateView.cs | 3 ++ OpenNest/Forms/MainForm.cs | 4 +- OpenNest/Forms/MainStatusBar.cs | 46 ++++++++++++++++++- docs/geometry/visual-overlap-check.md | 6 ++- 8 files changed, 179 insertions(+), 5 deletions(-) diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index ee2f32ba..daaf34cf 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -55,6 +55,34 @@ public sealed class OverlapReportState _ => "Overlaps: not checked" }; + /// A few steady words for the status bar; has the detail. + public string Summary => Status switch + { + OverlapCheckStatus.Checking => "Layout: checking…", + OverlapCheckStatus.Current => IsAllClear ? "Layout: OK" : "Layout: " + Findings(), + OverlapCheckStatus.Incomplete => "Layout: check incomplete", + OverlapCheckStatus.Failed => "Layout: check failed", + OverlapCheckStatus.Canceled => "Layout: check canceled", + OverlapCheckStatus.Stale => "Layout: out of date", + _ => "Layout: not checked" + }; + + /// True when reports findings or a check that did not finish. + public bool NeedsAttention => Status is OverlapCheckStatus.Incomplete or OverlapCheckStatus.Failed + || Status == OverlapCheckStatus.Current && !IsAllClear; + + private string Findings() + { + var overlaps = Report.Pairs.Count + Report.ClampFindings.Count + Report.CutOffClampFindings.Count; + var spacing = Report.SpacingPairs.Count; + var words = new List(); + if (overlaps != 0) + words.Add(overlaps == 1 ? "1 overlap" : $"{overlaps} overlaps"); + if (spacing != 0) + words.Add($"{spacing} too close"); + return string.Join(", ", words); + } + // Only a fully checked report that required a gap may clear spacing as well as overlaps. private string CurrentMessage() { diff --git a/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs b/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs index 4b494d4b..80aac806 100644 --- a/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs +++ b/OpenNest.Tests/Diagnostics/OverlapSpacingLabelTests.cs @@ -223,6 +223,45 @@ public class OverlapSpacingLabelTests Assert.False(state.SettledClear); } + [Theory] + [InlineData(4.5, "Layout: OK", false)] + [InlineData(4.1, "Layout: 1 too close", true)] + [InlineData(3.0, "Layout: 1 overlap", true)] + public void Summary_StaysShort(double x, string expected, bool attention) + { + var plate = PlateWith(Spacing, Square(0), Square(x)); + var state = new OverlapReportState(); + Assert.Equal("Layout: not checked", state.Summary); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + + Assert.Equal(expected, state.Summary); + Assert.Equal(attention, state.NeedsAttention); + } + + [Fact] + public void Summary_CountsOverlapsAndSpacingTogether() + { + var plate = PlateWith(Spacing, Square(0), Square(3), Square(3.5), Square(7.6)); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + + Assert.Equal("Layout: 3 overlaps, 1 too close", state.Summary); + } + + [Fact] + public void Summary_FlagsAnUnfinishedCheck() + { + var plate = PlateWith(Spacing, Square(0), new Part(new Drawing("invalid", new Program()))); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + Assert.Equal("Layout: check incomplete", state.Summary); + Assert.True(state.NeedsAttention); + + Assert.True(state.TryFail(state.Begin(plate), plate)); + Assert.Equal("Layout: check failed", state.Summary); + Assert.True(state.NeedsAttention); + } + [Fact] public void Presentation_ShowsPositionsNamesAndGap() { diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs index 26049c85..5fcd0a64 100644 --- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -434,6 +434,51 @@ public class PlateOverlapOverlayTests run.Pump(task); }); + [Fact] + public void SummaryStaysSteadyThroughARecheckAndNotifiesFromPaint() => RunSta(() => + { + using var run = new OverlayRun(); + run.View.Plate.Parts.RemoveAt(1); + run.EnableAutoCheck(); + var overlay = run.View.OverlapOverlay; + var task = run.AutoTick(); + Assert.Equal("Layout: checking…", overlay.Summary); + run.Next().Complete(); + run.Pump(task); + Assert.Equal("Layout: OK", overlay.Summary); + + var changes = new List(); + run.View.OverlapStateChanged += (_, _) => changes.Add(overlay.Summary); + run.View.Plate.Parts[0].Offset(1, 0); // A drag: only paint can notice it. + run.Paint(); + Assert.Equal("Layout: checking…", Assert.Single(changes.Distinct())); + Assert.False(overlay.NeedsAttention); + }); + + [Fact] + public void StatusBarLabelFollowsTheActiveViewWithoutTouchingTheStatusText() => RunSta(() => + { + using var form = new EditNestForm(new Nest("layout")); + var status = new ToolStripStatusLabel(); + var layout = MainStatusBar.CreateLayoutLabel(); + var bar = new MainStatusBar(status, new ToolStripStatusLabel(), new ToolStripStatusLabel(), + new ToolStripStatusLabel(), new ToolStripStatusLabel(), new ToolStripStatusLabel(), null, layout); + form.PlateView.Status = "Fill: 2 parts in 5 ms"; + + bar.Bind(form); + Assert.True(layout.Available); + Assert.Equal(form.PlateView.OverlapSummary, layout.Text); + Assert.Equal(form.PlateView.OverlapMessage, layout.ToolTipText); + form.PlateView.Plate.Parts.Add(Rectangle()); + form.PlateView.InvalidateOverlapCheck(); + Assert.Equal("Layout: checking…", layout.Text); + Assert.Equal("Fill: 2 parts in 5 ms", status.Text); + + bar.Bind(null); + Assert.False(layout.Available); + Assert.Equal("", layout.Text); + }); + private static bool CornerIsEmpty(OverlayRun run) { using var image = new Bitmap(64, 64); diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index 66d2c1de..f8e325f3 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -32,6 +32,7 @@ internal sealed class OverlapOverlayController : IDisposable private Func autoCheckUnits; private ObservableList observedParts; private CancellationTokenSource cancellation; + private string reportedSummary; private GraphicsPath path; private IReadOnlyList pathPairs; private float pathScale; @@ -80,6 +81,11 @@ internal sealed class OverlapOverlayController : IDisposable ? "Overlaps: check pending…" : state.Message; + /// The status bar's words: one steady "checking" for both pending and running. + public string Summary => IsSettling ? "Layout: checking…" : state.Summary; + + public bool NeedsAttention => !IsSettling && state.NeedsAttention; + /// /// The corner stays empty unless something is wrong: on a clear result, and while an /// automatic recheck settles after one or before the first. Views without automatic rechecks @@ -284,6 +290,7 @@ internal sealed class OverlapOverlayController : IDisposable if (toStatusBar || autoCheckUnits == null) view.Status = Message; view.Invalidate(); + reportedSummary = Summary; StateChanged?.Invoke(this, EventArgs.Empty); } @@ -375,6 +382,12 @@ internal sealed class OverlapOverlayController : IDisposable return; EnsureFresh(); UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below. + // A wait can start or end here without a state change, and the status bar must follow. + if (reportedSummary != Summary) + { + reportedSummary = Summary; + StateChanged?.Invoke(this, EventArgs.Empty); + } ValidateHoverTransform(); if (state.DisplayPairs.Count > 0) diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index e5864533..0073079b 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -355,6 +355,9 @@ namespace OpenNest.Controls } public OverlapCheckStatus OverlapStatus => overlapOverlay.Status; public PlateOverlapReport OverlapReport => overlapOverlay.Report; + public string OverlapSummary => overlapOverlay.Summary; + public string OverlapMessage => overlapOverlay.Message; + public bool OverlapNeedsAttention => overlapOverlay.NeedsAttention; public bool IsOverlapCheckRunning => overlapOverlay.IsRunning; public OverlapDisplayMode OverlapDisplay { diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index e145aec8..b1cd8ec9 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -20,6 +20,7 @@ namespace OpenNest.Forms private readonly MainStatusBar statusBar; private readonly ToolStripStatusLabel ledgerStatusLabel = MainStatusBar.CreateLedgerLabel(); + private readonly ToolStripStatusLabel layoutStatusLabel = MainStatusBar.CreateLayoutLabel(); private bool nestingInProgress; private CancellationTokenSource nestingCts; private readonly NestStorageController storage; @@ -114,9 +115,10 @@ namespace OpenNest.Forms Icon = Resources.app_icon; statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(materialStatusLabel) + 1, ledgerStatusLabel); ledgerStatusLabel.Click += LedgerStatusLabel_Click; + statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(plateUtilStatusLabel) + 1, layoutStatusLabel); statusStripToolTips = new StatusStripToolTips(statusStrip1); // The hold list lives in the ledger label's tooltip. statusBar = new MainStatusBar(statusLabel1, locationStatusLabel, selectionStatusLabel, - machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel); + machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel, layoutStatusLabel); btnCutPlanner = CreateActionButton("btnCutPlanner", "Plan Cutting", Resources.cut_planner, ToolbarPlanCutting_Click); btnSimulateCutting = CreateActionButton("btnSimulateCutting", "Simulate Cutting", diff --git a/OpenNest/Forms/MainStatusBar.cs b/OpenNest/Forms/MainStatusBar.cs index 530ec760..93aed25c 100644 --- a/OpenNest/Forms/MainStatusBar.cs +++ b/OpenNest/Forms/MainStatusBar.cs @@ -14,7 +14,7 @@ namespace OpenNest.Forms /// /// The main window's status labels for the active nest document: status text, cursor /// location, selection bounds, nest machine and material, MaterialLedger holds of a server nest, - /// and plate utilization. Plate number, size and + /// plate utilization and the automatic layout check. Plate number, size and /// quantity live in the plate view header instead. The location /// follows the mouse (the default, throttled), or only left clicks while click mode is on; /// the mode persists as a user setting. @@ -34,6 +34,7 @@ namespace OpenNest.Forms private readonly ToolStripStatusLabel material; private readonly ToolStripStatusLabel plateUtil; private readonly ToolStripStatusLabel ledger; + private readonly ToolStripStatusLabel layout; private readonly Timer locationSettleTimer; private EditNestForm form; @@ -42,9 +43,11 @@ namespace OpenNest.Forms public MainStatusBar(ToolStripStatusLabel status, ToolStripStatusLabel location, ToolStripStatusLabel selection, ToolStripStatusLabel machine, ToolStripStatusLabel material, - ToolStripStatusLabel plateUtil, ToolStripStatusLabel ledger = null) + ToolStripStatusLabel plateUtil, ToolStripStatusLabel ledger = null, + ToolStripStatusLabel layout = null) { this.ledger = ledger; + this.layout = layout; this.status = status; this.location = location; this.selection = selection; @@ -67,6 +70,7 @@ namespace OpenNest.Forms UpdatePlate(); UpdateLocation(); UpdateLedger(); + UpdateLayout(); if (form == null) { @@ -79,6 +83,7 @@ namespace OpenNest.Forms UpdateSelection(); var view = form.PlateView; view.StatusChanged += PlateView_StatusChanged; + view.OverlapStateChanged += PlateView_OverlapStateChanged; view.SelectionChanged += PlateView_SelectionChanged; view.PartAdded += PlateView_PartAdded; view.PartRemoved += PlateView_PartRemoved; @@ -96,12 +101,14 @@ namespace OpenNest.Forms view.MouseMove -= PlateView_MouseMove; view.MouseClick -= PlateView_MouseClick; view.StatusChanged -= PlateView_StatusChanged; + view.OverlapStateChanged -= PlateView_OverlapStateChanged; view.SelectionChanged -= PlateView_SelectionChanged; view.PartAdded -= PlateView_PartAdded; view.PartRemoved -= PlateView_PartRemoved; form.HoldStatus.Changed -= HoldStatus_Changed; form = null; UpdateLedger(); + UpdateLayout(); } public void ToggleLocationMode() @@ -118,6 +125,7 @@ namespace OpenNest.Forms UpdatePlate(); UpdateSelection(); UpdateLedger(); + UpdateLayout(); } /// The hidden-by-default hold label MainForm inserts after the material label. @@ -155,6 +163,38 @@ namespace OpenNest.Forms ledger.ToolTipText = tip + Environment.NewLine + (holds.IsRefreshing ? "Refreshing…" : "Click to refresh."); } + /// The layout check label MainForm inserts after utilization; hidden without a nest. + internal static ToolStripStatusLabel CreateLayoutLabel() => new() + { + Name = "layoutStatusLabel", + BorderSides = ToolStripStatusLabelBorderSides.Left, + Padding = new Padding(5, 0, 5, 0), + Visible = false, + }; + + /// + /// The automatic layout check has its own label: unlike the status text, it may change + /// on every edit without replacing the last command's message. + /// + public void UpdateLayout() + { + if (layout == null) + return; + var view = form?.PlateView; + layout.Visible = view != null; + if (view == null) + { + layout.Text = string.Empty; + layout.ToolTipText = string.Empty; + return; + } + + if (layout.Text != view.OverlapSummary) + layout.Text = view.OverlapSummary; + layout.ForeColor = view.OverlapNeedsAttention ? Color.DarkRed : SystemColors.ControlText; + layout.ToolTipText = view.OverlapMessage; + } + public void UpdateLocation() { if (form == null) @@ -313,6 +353,8 @@ namespace OpenNest.Forms private void PlateView_StatusChanged(object sender, EventArgs e) => status.Text = form.PlateView.Status; + private void PlateView_OverlapStateChanged(object sender, EventArgs e) => UpdateLayout(); + private void PlateView_SelectionChanged(object sender, EventArgs e) => UpdateSelection(); private void HoldStatus_Changed(object sender, EventArgs e) => UpdateLedger(); diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index de57678d..d824b248 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -61,8 +61,10 @@ In a nest window the active plate is checked automatically. Any layout edit Only highlights involving changed ordered input slots are removed; once the layout has been unchanged for 0.5 s and no mouse button, modal dialog, or fill progress window is active, the check reruns. -Automatic results appear only in the canvas label, so the status bar keeps the -last command's message, and an automatic check keeps Display > Off rather than +Automatic results do not replace the status bar's message, so it keeps the last +command's text; a separate **Layout** section beside utilization follows the check +instead, as `Layout: OK`, `Layout: checking…` or `Layout: 1 overlap, 3 too close`, +in red for findings or an unfinished check, with the full wording in its tooltip, and an automatic check keeps Display > Off rather than switching to Areas. Rechecks are incremental: drawing material is prepared once and reused, and pairs whose two parts have not moved reuse the previous result, so only the moved parts' neighbors are clipped again. Canceling a check is