From f78cc78a65d69178e2a7ecfa8cebc0f712b2a168 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 5 Apr 2026 18:30:07 -0400 Subject: [PATCH] fix: improve fill progress reporting and engine pipeline - Strategies now promote results to IsOverallBest when they beat the pipeline best, so the UI updates immediately on improvement rather than waiting for each phase to complete - PlateView only updates the main view on overall-best results, fixing intermediate angle-sweep layouts leaking to the plate display - Skip Row/Column strategies for rectangle parts (redundant with Linear) - Intercept Escape key at MainForm level via ProcessCmdKey so it always reaches the active PlateView regardless of focus state - Restore keyboard focus to PlateView after fill progress form closes - Remnant engines use SelectBestFitPair for orientation-aware pair selection; DefaultNestEngine tries both landscape and portrait pairs - RemnantFiller preserves more parts during topmost-part removal Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/DefaultNestEngine.cs | 65 +++++++++++++++---- OpenNest.Engine/Fill/RemnantFiller.cs | 2 +- OpenNest.Engine/HorizontalRemnantEngine.cs | 15 +++++ OpenNest.Engine/NestEngineBase.cs | 7 +- .../Strategies/ColumnFillStrategy.cs | 3 + OpenNest.Engine/Strategies/FillContext.cs | 15 ++++- OpenNest.Engine/Strategies/RowFillStrategy.cs | 3 + OpenNest.Engine/VerticalRemnantEngine.cs | 15 +++++ OpenNest/Controls/PlateView.cs | 22 +++++-- OpenNest/Forms/MainForm.cs | 11 ++++ 10 files changed, 135 insertions(+), 23 deletions(-) diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index 1183b41..3d570a6 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -139,28 +139,63 @@ namespace OpenNest var bestFits = BestFitCache.GetOrCompute( drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); - var best = bestFits.FirstOrDefault(r => r.Keep); + var best = SelectBestFitPair(bestFits); if (best == null) return null; - var parts = best.BuildParts(drawing); + // BuildParts produces landscape orientation (Width >= Height). + // Try both landscape and portrait (90° rotated) and let the + // engine's comparer pick the better orientation. + var landscape = best.BuildParts(drawing); + var portrait = RotatePair90(landscape); - // BuildParts positions at origin — offset to work area. + var lFits = TryOffsetToWorkArea(landscape, workArea); + var pFits = TryOffsetToWorkArea(portrait, workArea); + + if (!lFits && !pFits) + return null; + if (lFits && pFits) + return IsBetterFill(portrait, landscape, workArea) ? portrait : landscape; + return lFits ? landscape : portrait; + } + + private static List RotatePair90(List parts) + { + var rotated = new List(parts.Count); + foreach (var p in parts) + rotated.Add((Part)p.Clone()); + + var bbox = ((IEnumerable)rotated).GetBoundingBox(); + var center = bbox.Center; + + foreach (var p in rotated) + p.Rotate(-Angle.HalfPI, center); + + var newBbox = ((IEnumerable)rotated).GetBoundingBox(); + var offset = new Vector(-newBbox.Left, -newBbox.Bottom); + foreach (var p in rotated) + { + p.Offset(offset); + p.UpdateBounds(); + } + + return rotated; + } + + private static bool TryOffsetToWorkArea(List parts, Box workArea) + { var bbox = ((IEnumerable)parts).GetBoundingBox(); + if (bbox.Width > workArea.Width + Tolerance.Epsilon || + bbox.Length > workArea.Length + Tolerance.Epsilon) + return false; + var offset = workArea.Location - bbox.Location; foreach (var p in parts) { p.Offset(offset); p.UpdateBounds(); } - - // Verify pair fits in work area. - bbox = ((IEnumerable)parts).GetBoundingBox(); - if (bbox.Width > workArea.Width + Tolerance.Epsilon || - bbox.Length > workArea.Length + Tolerance.Epsilon) - return null; - - return parts; + return true; } /// @@ -309,14 +344,18 @@ namespace OpenNest // during progress reporting. PhaseResults.Add(phaseResult); - if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea)) + // FillContext.ReportProgress updates CurrentBest during the + // strategy's angle sweep. This catches strategies that return a + // result without reporting it (e.g. RectBestFit). + var improved = context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea); + if (improved) { context.CurrentBest = result; context.CurrentBestScore = FillScore.Compute(result, context.WorkArea); context.WinnerPhase = strategy.Phase; } - if (context.CurrentBest != null && context.CurrentBest.Count > 0) + if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0) { ReportProgress(context.Progress, new ProgressReport { diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs index 13f8fa3..c6eb110 100644 --- a/OpenNest.Engine/Fill/RemnantFiller.cs +++ b/OpenNest.Engine/Fill/RemnantFiller.cs @@ -106,7 +106,7 @@ namespace OpenNest.Engine.Fill // rectangular obstacle boundary. Without this, gaps between // individual bounding boxes cause the next drawing to fill // into inter-row spaces, producing an interleaved layout. - if (placed.Count > 1) + if (placed.Count > 2) RemoveTopmostPart(placed); allParts.AddRange(placed); diff --git a/OpenNest.Engine/HorizontalRemnantEngine.cs b/OpenNest.Engine/HorizontalRemnantEngine.cs index 16b31eb..28a34a6 100644 --- a/OpenNest.Engine/HorizontalRemnantEngine.cs +++ b/OpenNest.Engine/HorizontalRemnantEngine.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using OpenNest.Engine; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Fill; using OpenNest.Geometry; using OpenNest.Math; @@ -26,6 +27,20 @@ namespace OpenNest public override ShrinkAxis TrimAxis => ShrinkAxis.Length; + protected override BestFitResult SelectBestFitPair(List results) + { + BestFitResult best = null; + + foreach (var r in results) + { + if (!r.Keep) continue; + if (best == null || r.BoundingHeight < best.BoundingHeight) + best = r; + } + + return best; + } + public override List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { var baseAngles = new List { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index b2b2349..226d21f 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -56,6 +56,11 @@ namespace OpenNest protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection); + protected virtual BestFitResult SelectBestFitPair(List results) + { + return results.FirstOrDefault(r => r.Keep); + } + // --- Virtual methods (side-effect-free, return parts) --- public virtual List Fill(NestItem item, Box workArea, @@ -333,7 +338,7 @@ namespace OpenNest var bestFits = BestFitCache.GetOrCompute( item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); - var bestFit = bestFits.FirstOrDefault(r => r.Keep); + var bestFit = SelectBestFitPair(bestFits); if (bestFit == null) continue; var parts = bestFit.BuildParts(item.Drawing); diff --git a/OpenNest.Engine/Strategies/ColumnFillStrategy.cs b/OpenNest.Engine/Strategies/ColumnFillStrategy.cs index a4ac975..c300171 100644 --- a/OpenNest.Engine/Strategies/ColumnFillStrategy.cs +++ b/OpenNest.Engine/Strategies/ColumnFillStrategy.cs @@ -11,6 +11,9 @@ public class ColumnFillStrategy : IFillStrategy public List Fill(FillContext context) { + if (context.PartType == PartType.Rectangle) + return null; + var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true }; return filler.Fill(); } diff --git a/OpenNest.Engine/Strategies/FillContext.cs b/OpenNest.Engine/Strategies/FillContext.cs index d03495e..54ca8d1 100644 --- a/OpenNest.Engine/Strategies/FillContext.cs +++ b/OpenNest.Engine/Strategies/FillContext.cs @@ -32,16 +32,29 @@ namespace OpenNest.Engine.Strategies /// /// Standard progress reporting for strategies and fillers. Reports intermediate /// results using the current ActivePhase, PlateNumber, and WorkArea. + /// When the reported parts beat the current pipeline best, promotes the + /// result to IsOverallBest so the UI updates immediately. /// public void ReportProgress(List parts, string description) { + var isNewBest = parts != null && parts.Count > 0 + && Policy.Comparer.IsBetter(parts, CurrentBest, WorkArea); + + if (isNewBest) + { + CurrentBest = parts; + CurrentBestScore = FillScore.Compute(parts, WorkArea); + WinnerPhase = ActivePhase; + } + NestEngineBase.ReportProgress(Progress, new ProgressReport { Phase = ActivePhase, PlateNumber = PlateNumber, - Parts = parts, + Parts = isNewBest ? parts : CurrentBest, WorkArea = WorkArea, Description = description, + IsOverallBest = isNewBest, }); } } diff --git a/OpenNest.Engine/Strategies/RowFillStrategy.cs b/OpenNest.Engine/Strategies/RowFillStrategy.cs index 0570e97..29cce72 100644 --- a/OpenNest.Engine/Strategies/RowFillStrategy.cs +++ b/OpenNest.Engine/Strategies/RowFillStrategy.cs @@ -11,6 +11,9 @@ public class RowFillStrategy : IFillStrategy public List Fill(FillContext context) { + if (context.PartType == PartType.Rectangle) + return null; + var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true }; return filler.Fill(); } diff --git a/OpenNest.Engine/VerticalRemnantEngine.cs b/OpenNest.Engine/VerticalRemnantEngine.cs index 510f1bb..a398761 100644 --- a/OpenNest.Engine/VerticalRemnantEngine.cs +++ b/OpenNest.Engine/VerticalRemnantEngine.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using OpenNest.Engine; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Fill; using OpenNest.Geometry; using OpenNest.Math; @@ -24,6 +25,20 @@ namespace OpenNest public override NestDirection? PreferredDirection => NestDirection.Horizontal; + protected override BestFitResult SelectBestFitPair(List results) + { + BestFitResult best = null; + + foreach (var r in results) + { + if (!r.Keep) continue; + if (best == null || r.BoundingHeight < best.BoundingHeight) + best = r; + } + + return best; + } + public override List BuildAngles(NestItem item, ClassificationResult classification, Box workArea) { var baseAngles = new List { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI }; diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index ddd6486..016a5ba 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -412,6 +412,16 @@ namespace OpenNest.Controls } } + public void ProcessEscapeKey() + { + if (currentAction.IsBusy()) + currentAction.CancelAction(); + else if (currentAction is ActionSelect && previousAction != null) + RestorePreviousAction(); + else + SetAction(typeof(ActionSelect)); + } + protected override bool ProcessDialogKey(Keys keyData) { // Only handle TAB, RETURN, ESC, and ARROW KEYS here. @@ -420,12 +430,7 @@ namespace OpenNest.Controls switch (keyData) { case Keys.Escape: - if (currentAction.IsBusy()) - currentAction.CancelAction(); - else if (currentAction is ActionSelect && previousAction != null) - RestorePreviousAction(); - else - SetAction(typeof(ActionSelect)); + ProcessEscapeKey(); break; case Keys.Left: @@ -791,9 +796,11 @@ namespace OpenNest.Controls progressForm.UpdateProgress(p); if (p.IsOverallBest) + { progressForm.UpdatePreview(p.BestParts); + SetActiveParts(p.BestParts); + } - SetActiveParts(p.BestParts); ActiveWorkArea = p.ActiveWorkArea; }); @@ -837,6 +844,7 @@ namespace OpenNest.Controls ActiveWorkArea = null; progressForm.Close(); cts.Dispose(); + Focus(); } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 9cbc52f..279f871 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -29,6 +29,17 @@ namespace OpenNest.Forms private const float ZoomInFactor = 1.5f; private const float ZoomOutFactor = 1.0f / ZoomInFactor; + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (keyData == Keys.Escape && activeForm?.PlateView != null) + { + activeForm.PlateView.ProcessEscapeKey(); + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + public MainForm() { InitializeComponent();