fix: consolidate tail plates by upgrading instead of creating new plates

Three fixes to TryUpgradeOrNewPlate and a new post-pass:

1. Change ShouldUpgrade from < to <= so upgrade wins when costs are
   tied (e.g., all zero) — previously 0 < 0 was always false

2. Guard against "upgrades" that shrink a dimension — when options are
   sorted by cost and costs are equal, the next option may have a
   smaller length despite higher width (e.g., 72x96 after 60x144)

3. Revert plate size when upgrade fill fails — the plate was being
   resized before confirming parts fit, leaving it at the wrong size

4. Add TryConsolidateTailPlates post-pass: after all nesting, find the
   lowest-utilization new plate and try to absorb its parts into
   another plate via upgrade. Eliminates wasteful tail plates (e.g.,
   a 48x96 plate at 21% util for 2 parts that fit in upgraded space).

Real nest file: 6 plates → 5 plates, all 43 parts placed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 16:41:49 -04:00
parent 62d9dce0b1
commit cca70db547

View File

@@ -160,7 +160,7 @@ namespace OpenNest
return new UpgradeDecision
{
ShouldUpgrade = upgradeCost < netNewCost,
ShouldUpgrade = upgradeCost <= netNewCost,
UpgradeCost = upgradeCost,
NewPlateCost = netNewCost,
};
@@ -292,6 +292,9 @@ namespace OpenNest
CreateSharedPlates(leftovers);
}
if (_plateOptions != null && _plateOptions.Count > 0 && !_token.IsCancellationRequested)
TryConsolidateTailPlates();
foreach (var item in sorted.Where(i => i.Quantity > 0))
result.UnplacedItems.Add(item);
@@ -473,6 +476,12 @@ namespace OpenNest
for (var i = currentIdx + 1; i < sortedOptions.Count; i++)
{
var upgradeOption = sortedOptions[i];
// Only consider options that are at least as large in both dimensions.
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
continue;
var smallestNew = sortedOptions.FirstOrDefault(o =>
{
var ww = o.Width - _template.EdgeSpacing.Left - _template.EdgeSpacing.Right;
@@ -490,6 +499,9 @@ namespace OpenNest
if (decision.ShouldUpgrade)
{
var oldSize = pr.Plate.Size;
var oldChosenSize = pr.ChosenSize;
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
pr.ChosenSize = upgradeOption;
@@ -497,6 +509,10 @@ namespace OpenNest
if (remainingArea.Count > 0 && FillAndPlace(pr, remainingArea[0], item) > 0)
return true;
// Revert if nothing was placed.
pr.Plate.Size = oldSize;
pr.ChosenSize = oldChosenSize;
}
break;
}
@@ -504,5 +520,78 @@ namespace OpenNest
return false;
}
private void TryConsolidateTailPlates()
{
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
if (activePlates.Count < 2)
return;
var sortedOptions = _plateOptions.OrderBy(o => o.Cost).ToList();
// Try to absorb the smallest-utilization new plate into another plate via upgrade.
var donor = activePlates.OrderBy(p => p.Plate.Utilization()).First();
var donorParts = donor.Parts.ToList();
foreach (var target in activePlates)
{
if (target == donor || target.ChosenSize == null)
continue;
var currentOption = target.ChosenSize;
// Try each larger option that doesn't shrink any dimension.
foreach (var upgradeOption in sortedOptions.Where(o =>
o.Width >= currentOption.Width - Tolerance.Epsilon
&& o.Length >= currentOption.Length - Tolerance.Epsilon
&& (o.Width > currentOption.Width + Tolerance.Epsilon
|| o.Length > currentOption.Length + Tolerance.Epsilon)))
{
var oldSize = target.Plate.Size;
var oldChosenSize = target.ChosenSize;
target.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
target.ChosenSize = upgradeOption;
var remnants = RemnantFinder.FromPlate(target.Plate).FindRemnants();
if (remnants.Count == 0)
{
target.Plate.Size = oldSize;
target.ChosenSize = oldChosenSize;
continue;
}
// Try to pack all donor parts into the remnant space.
var engine = NestEngineRegistry.Create(target.Plate);
var tempItems = donorParts
.GroupBy(p => p.BaseDrawing.Name)
.Select(g => new NestItem
{
Drawing = g.First().BaseDrawing,
Quantity = g.Count(),
})
.ToList();
var placed = engine.PackArea(remnants[0], tempItems, _progress, _token);
if (placed.Count >= donorParts.Count)
{
// All donor parts fit — absorb them.
target.Plate.Parts.AddRange(placed);
target.Parts.AddRange(placed);
foreach (var p in donorParts)
donor.Plate.Parts.Remove(p);
donor.Parts.Clear();
_platePool.Remove(donor);
return;
}
// Didn't fit all parts — revert.
target.Plate.Size = oldSize;
target.ChosenSize = oldChosenSize;
}
}
}
}
}