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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:30:07 -04:00
parent 37130e8a28
commit f78cc78a65
10 changed files with 135 additions and 23 deletions

View File

@@ -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<Part> RotatePair90(List<Part> parts)
{
var rotated = new List<Part>(parts.Count);
foreach (var p in parts)
rotated.Add((Part)p.Clone());
var bbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
var center = bbox.Center;
foreach (var p in rotated)
p.Rotate(-Angle.HalfPI, center);
var newBbox = ((IEnumerable<IBoundable>)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<Part> parts, Box workArea)
{
var bbox = ((IEnumerable<IBoundable>)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<IBoundable>)parts).GetBoundingBox();
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
bbox.Length > workArea.Length + Tolerance.Epsilon)
return null;
return parts;
return true;
}
/// <summary>
@@ -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
{

View File

@@ -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);

View File

@@ -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<BestFitResult> 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<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };

View File

@@ -56,6 +56,11 @@ namespace OpenNest
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
protected virtual BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
return results.FirstOrDefault(r => r.Keep);
}
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> 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);

View File

@@ -11,6 +11,9 @@ public class ColumnFillStrategy : IFillStrategy
public List<Part> Fill(FillContext context)
{
if (context.PartType == PartType.Rectangle)
return null;
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
return filler.Fill();
}

View File

@@ -32,16 +32,29 @@ namespace OpenNest.Engine.Strategies
/// <summary>
/// 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.
/// </summary>
public void ReportProgress(List<Part> 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,
});
}
}

View File

@@ -11,6 +11,9 @@ public class RowFillStrategy : IFillStrategy
public List<Part> Fill(FillContext context)
{
if (context.PartType == PartType.Rectangle)
return null;
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
return filler.Fill();
}

View File

@@ -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<BestFitResult> 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<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };

View File

@@ -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();
}
}

View File

@@ -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();