feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill

Replaces the old orientation-based strip nesting (TryOrientation,
SelectStripItemIndex, EstimateStripDimension, ShrinkFill helpers) with
a call to IterativeShrinkFiller.Fill for multi-quantity items, plus a
RemnantFinder-based PackArea pass for singles and leftovers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 10:37:21 -04:00
parent c31ef9f80c
commit 8957b20bac

View File

@@ -15,11 +15,10 @@ namespace OpenNest
public override string Name => "Strip";
public override string Description => "Strip-based nesting for mixed-drawing layouts";
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
@@ -49,66 +48,10 @@ namespace OpenNest
}
/// <summary>
/// Selects the item that consumes the most plate area (bounding box area x quantity).
/// Returns the index into the items list.
/// </summary>
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
{
var bestIndex = 0;
var bestArea = 0.0;
for (var i = 0; i < items.Count; i++)
{
var bbox = items[i].Drawing.Program.BoundingBox();
var qty = items[i].Quantity > 0
? items[i].Quantity
: (int)(workArea.Area() / bbox.Area());
var totalArea = bbox.Area() * qty;
if (totalArea > bestArea)
{
bestArea = totalArea;
bestIndex = i;
}
}
return bestIndex;
}
/// <summary>
/// Estimates the strip dimension (height for bottom, width for left) needed
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
/// This is only an estimate for the shrink loop starting point — the actual fill
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
/// </summary>
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
{
var bbox = item.Drawing.Program.BoundingBox();
var qty = item.Quantity > 0
? item.Quantity
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
var perRow0 = (int)(stripLength / bbox.Width);
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
var dim0 = rows0 * bbox.Length;
// At 90 deg: rotated bounding box (Width and Length swap)
var perRow90 = (int)(stripLength / bbox.Length);
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
var dim90 = rows90 * bbox.Width;
var estimate = System.Math.Min(dim0, dim90);
// Clamp to available dimension
return System.Math.Min(estimate, maxDimension);
}
/// <summary>
/// Multi-drawing strip nesting strategy.
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
/// in both bottom and left orientations, fills remnants with remaining drawings,
/// and returns the denser result.
/// Multi-drawing iterative shrink-fill strategy.
/// Each multi-quantity drawing gets shrink-filled into the tightest
/// sub-region using dual-direction selection. Singles and leftovers
/// are packed at the end.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
@@ -118,147 +61,72 @@ namespace OpenNest
var workArea = Plate.WorkArea();
// Select which item gets the strip treatment.
var stripIndex = SelectStripItemIndex(items, workArea);
var stripItem = items[stripIndex];
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
// Separate multi-quantity from singles.
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
// Try both orientations.
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
// Pick the better result.
var winner = bottomResult.Score >= leftResult.Score
? bottomResult.Parts
: leftResult.Parts;
var allParts = new List<Part>();
// Deduct placed quantities from the original items.
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
fillItems, workArea, fillFunc, Plate.PartSpacing, token);
allParts.AddRange(shrinkResult.Parts);
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
}
// Phase 2: Pack singles + leftovers into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && !token.IsCancellationRequested)
{
// Reconstruct remaining area from placed parts.
var packArea = workArea;
if (allParts.Count > 0)
{
var obstacles = allParts
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
.ToList();
var finder = new RemnantFinder(workArea, obstacles);
var remnants = finder.FindRemnants();
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
}
if (packArea.Width > 0 && packArea.Length > 0)
{
var packParts = PackArea(packArea, packItems, progress, token);
allParts.AddRange(packParts);
}
}
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return winner;
return allParts;
}
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
{
var result = new StripNestResult { Direction = direction };
if (token.IsCancellationRequested)
return result;
// Estimate initial strip dimension.
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
// Create the initial strip box.
var stripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
// Shrink to tightest strip.
var shrinkAxis = direction == StripDirection.Bottom
? ShrinkAxis.Height : ShrinkAxis.Width;
Func<NestItem, Box, List<Part>> stripFill = (ni, b) =>
{
var trialInner = new DefaultNestEngine(Plate);
return trialInner.Fill(ni, b, progress, token);
};
var shrinkResult = ShrinkFiller.Shrink(stripFill,
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
stripBox, Plate.PartSpacing, shrinkAxis, token);
if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0)
return result;
var bestParts = shrinkResult.Parts;
var bestDim = shrinkResult.Dimension;
// Build remnant box with spacing gap.
var spacing = Plate.PartSpacing;
var remnantBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y + bestDim + spacing,
workArea.Width, workArea.Length - bestDim - spacing)
: new Box(workArea.X + bestDim + spacing, workArea.Y,
workArea.Width - bestDim - spacing, workArea.Length);
// Collect all parts.
var allParts = new List<Part>(bestParts);
// If strip item was only partially placed, add leftovers to remainder.
var placed = bestParts.Count;
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
var effectiveRemainder = new List<NestItem>(remainderItems);
if (leftover > 0)
{
effectiveRemainder.Add(new NestItem
{
Drawing = stripItem.Drawing,
Quantity = leftover
});
}
// Sort remainder by descending bounding box area x quantity.
effectiveRemainder = effectiveRemainder
.OrderByDescending(i =>
{
var bb = i.Drawing.Program.BoundingBox();
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
})
.ToList();
// Fill remnants
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var remnantProgress = progress != null
? new AccumulatingProgress(progress, allParts)
: (IProgress<NestProgress>)null;
var remnantFiller = new RemnantFiller(workArea, spacing);
remnantFiller.AddObstacles(allParts);
Func<NestItem, Box, List<Part>> remnantFillFunc = (ni, b) =>
ShrinkFill(ni, b, remnantProgress, token);
var additional = remnantFiller.FillItems(effectiveRemainder,
remnantFillFunc, token, remnantProgress);
allParts.AddRange(additional);
}
result.Parts = allParts;
result.StripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
result.Score = FillScore.Compute(allParts, workArea);
return result;
}
private List<Part> ShrinkFill(NestItem item, Box box,
IProgress<NestProgress> progress, CancellationToken token)
{
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, null, token);
};
var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
Plate.PartSpacing, ShrinkAxis.Height, token);
return heightResult.Parts;
}
}
}