refactor(engine): introduce PairFillResult and remove FillRemainingStrip

PairFiller now returns PairFillResult (Parts + BestFits) instead of
using a mutable BestFits property. Extracted EvaluateCandidates,
TryReduceWorkArea, and BuildTilingAngles for clarity. Simplified the
candidate loop by leveraging FillScore comparison semantics.

Removed FillRemainingStrip and all its helpers (FindPlacedEdge,
BuildRemainingStrip, BuildRotationSet, FindBestFill, TryFewerRows,
RemainderPatterns) from FillLinear — these were a major bottleneck in
strip nesting, running expensive fills on undersized remnant strips.
ShrinkFiller + RemnantFiller already handle space optimization, making
the remainder strip fill redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:53:23 -04:00
parent 8bfc13d529
commit e969260f3d
7 changed files with 380 additions and 334 deletions

View File

@@ -19,11 +19,6 @@ namespace OpenNest.Engine.Fill
public double HalfSpacing => PartSpacing / 2;
/// <summary>
/// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips.
/// </summary>
public List<Pattern> RemainderPatterns { get; set; }
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
@@ -346,215 +341,9 @@ namespace OpenNest.Engine.Fill
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
// Step 3: Fill remaining strip
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
if (remaining.Count > 0)
gridResult.AddRange(remaining);
// Step 4: Try fewer rows optimization
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
if (fewerResult != null && fewerResult.Count > gridResult.Count)
return fewerResult;
return gridResult;
}
/// <summary>
/// Tries removing the last row/column from the grid and re-filling the
/// larger remainder strip. Returns null if this doesn't improve the total.
/// </summary>
private List<Part> TryFewerRows(
List<Part> fullResult, Pattern rowPattern, Pattern seedPattern,
NestDirection tiledAxis, NestDirection primaryAxis)
{
var rowPartCount = rowPattern.Parts.Count;
if (fullResult.Count < rowPartCount * 2)
return null;
var fewerParts = new List<Part>(fullResult.Count - rowPartCount);
for (var i = 0; i < fullResult.Count - rowPartCount; i++)
fewerParts.Add(fullResult[i]);
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
if (remaining.Count <= rowPartCount)
return null;
fewerParts.AddRange(remaining);
return fewerParts;
}
/// <summary>
/// After tiling full rows/columns, fills the remaining strip with individual
/// parts. The strip is the leftover space along the tiled axis between the
/// last full row/column and the work area boundary. Each unique drawing and
/// rotation from the seed pattern is tried in both directions.
/// </summary>
private List<Part> FillRemainingStrip(
List<Part> placedParts, Pattern seedPattern,
NestDirection tiledAxis, NestDirection primaryAxis)
{
var placedEdge = FindPlacedEdge(placedParts, tiledAxis);
var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis);
if (remainingStrip == null)
return new List<Part>();
var rotations = BuildRotationSet(seedPattern);
var best = FindBestFill(rotations, remainingStrip);
if (RemainderPatterns != null)
{
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns");
foreach (var pattern in RemainderPatterns)
{
var filler = new FillLinear(remainingStrip, PartSpacing);
var h = filler.Fill(pattern, NestDirection.Horizontal);
var v = filler.Fill(pattern, NestDirection.Vertical);
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}");
if (h != null && h.Count > (best?.Count ?? 0))
best = h;
if (v != null && v.Count > (best?.Count ?? 0))
best = v;
}
System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}");
}
return best ?? new List<Part>();
}
private static double FindPlacedEdge(List<Part> placedParts, NestDirection tiledAxis)
{
var placedEdge = double.MinValue;
foreach (var part in placedParts)
{
var edge = tiledAxis == NestDirection.Vertical
? part.BoundingBox.Top
: part.BoundingBox.Right;
if (edge > placedEdge)
placedEdge = edge;
}
return placedEdge;
}
private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis)
{
if (tiledAxis == NestDirection.Vertical)
{
var bottom = placedEdge + PartSpacing;
var height = WorkArea.Top - bottom;
if (height <= Tolerance.Epsilon)
return null;
return new Box(WorkArea.X, bottom, WorkArea.Width, height);
}
else
{
var left = placedEdge + PartSpacing;
var width = WorkArea.Right - left;
if (width <= Tolerance.Epsilon)
return null;
return new Box(left, WorkArea.Y, width, WorkArea.Length);
}
}
/// <summary>
/// Builds a set of (drawing, rotation) candidates: cardinal orientations
/// (0° and 90°) for each unique drawing, plus any seed pattern rotations
/// not already covered.
/// </summary>
private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern)
{
var rotations = new List<(Drawing drawing, double rotation)>();
var drawings = new List<Drawing>();
foreach (var seedPart in seedPattern.Parts)
{
var found = false;
foreach (var d in drawings)
{
if (d == seedPart.BaseDrawing)
{
found = true;
break;
}
}
if (!found)
drawings.Add(seedPart.BaseDrawing);
}
foreach (var drawing in drawings)
{
rotations.Add((drawing, 0));
rotations.Add((drawing, Angle.HalfPI));
}
foreach (var seedPart in seedPattern.Parts)
{
var skip = false;
foreach (var (d, r) in rotations)
{
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
{
skip = true;
break;
}
}
if (!skip)
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
}
return rotations;
}
/// <summary>
/// Tries all rotation candidates in both directions in parallel, returns the
/// fill with the most parts.
/// </summary>
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
{
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
Parallel.ForEach(rotations, entry =>
{
var filler = new FillLinear(strip, PartSpacing);
var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
if (h != null && h.Count > 0)
bag.Add(h);
if (v != null && v.Count > 0)
bag.Add(v);
});
List<Part> best = null;
foreach (var candidate in bag)
{
if (best == null || candidate.Count > best.Count)
best = candidate;
}
return best ?? new List<Part>();
}
/// <summary>
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
/// </summary>

View File

@@ -2,6 +2,7 @@ using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Engine.Fill
{
@@ -74,16 +75,33 @@ namespace OpenNest.Engine.Fill
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
{
var target = ni.Quantity > 0 ? ni.Quantity : 0;
var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar);
var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar);
// Run height and width shrinks in parallel — they are independent
// (same inputs, no shared mutable state).
ShrinkResult heightResult = null;
ShrinkResult widthResult = null;
Parallel.Invoke(
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
);
var heightScore = FillScore.Compute(heightResult.Parts, box);
var widthScore = FillScore.Compute(widthResult.Parts, box);
var best = widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
// Sort pair groups so shorter/narrower groups are closer to the origin,
// creating a staircase profile that maximizes remnant area.
// Height shrink → columns vary in height → sort columns.
// Width shrink → rows vary in width → sort rows.
if (widthScore > heightScore)
SortRowsByWidth(best, spacing);
else
SortColumnsByHeight(best, spacing);
// Report the winner as overall best so the UI shows it as settled.
if (progress != null && best != null && best.Count > 0)
{
@@ -134,5 +152,171 @@ namespace OpenNest.Engine.Fill
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
}
/// <summary>
/// Sorts pair columns by height (shortest first on the left) to create
/// a staircase profile that maximizes usable remnant area.
/// </summary>
internal static void SortColumnsByHeight(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return;
// Sort parts by Left edge for grouping.
parts.Sort((a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
// Group parts into columns by X overlap.
var columns = new List<List<Part>>();
var column = new List<Part> { parts[0] };
var columnRight = parts[0].BoundingBox.Right;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Left > columnRight + spacing / 2)
{
columns.Add(column);
column = new List<Part> { parts[i] };
columnRight = parts[i].BoundingBox.Right;
}
else
{
column.Add(parts[i]);
if (parts[i].BoundingBox.Right > columnRight)
columnRight = parts[i].BoundingBox.Right;
}
}
columns.Add(column);
if (columns.Count <= 1)
return;
// Measure inter-column gap from original layout.
var gap = MinLeft(columns[1]) - MaxRight(columns[0]);
// Sort columns by height ascending (shortest first).
columns.Sort((a, b) => MaxTop(a).CompareTo(MaxTop(b)));
// Reposition columns left-to-right.
var x = parts[0].BoundingBox.Left; // parts already sorted by Left
foreach (var col in columns)
{
var colLeft = MinLeft(col);
var dx = x - colLeft;
if (System.Math.Abs(dx) > OpenNest.Math.Tolerance.Epsilon)
{
var offset = new Vector(dx, 0);
foreach (var part in col)
part.Offset(offset);
}
x = MaxRight(col) + gap;
}
// Rebuild the parts list in column order.
parts.Clear();
foreach (var col in columns)
parts.AddRange(col);
}
/// <summary>
/// Sorts pair rows by width (narrowest first on the bottom) to create
/// a staircase profile on the right side that maximizes usable remnant area.
/// </summary>
internal static void SortRowsByWidth(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return;
// Sort parts by Bottom edge for grouping.
parts.Sort((a, b) => a.BoundingBox.Bottom.CompareTo(b.BoundingBox.Bottom));
// Group parts into rows by Y overlap.
var rows = new List<List<Part>>();
var row = new List<Part> { parts[0] };
var rowTop = parts[0].BoundingBox.Top;
for (var i = 1; i < parts.Count; i++)
{
if (parts[i].BoundingBox.Bottom > rowTop + spacing / 2)
{
rows.Add(row);
row = new List<Part> { parts[i] };
rowTop = parts[i].BoundingBox.Top;
}
else
{
row.Add(parts[i]);
if (parts[i].BoundingBox.Top > rowTop)
rowTop = parts[i].BoundingBox.Top;
}
}
rows.Add(row);
if (rows.Count <= 1)
return;
// Measure inter-row gap from original layout.
var gap = MinBottom(rows[1]) - MaxTop(rows[0]);
// Sort rows by width ascending (narrowest first).
rows.Sort((a, b) => MaxRight(a).CompareTo(MaxRight(b)));
// Reposition rows bottom-to-top.
var y = parts[0].BoundingBox.Bottom; // parts already sorted by Bottom
foreach (var r in rows)
{
var rowBottom = MinBottom(r);
var dy = y - rowBottom;
if (System.Math.Abs(dy) > OpenNest.Math.Tolerance.Epsilon)
{
var offset = new Vector(0, dy);
foreach (var part in r)
part.Offset(offset);
}
y = MaxTop(r) + gap;
}
// Rebuild the parts list in row order.
parts.Clear();
foreach (var r in rows)
parts.AddRange(r);
}
private static double MaxTop(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Top > max) max = p.BoundingBox.Top;
return max;
}
private static double MaxRight(List<Part> col)
{
var max = double.MinValue;
foreach (var p in col)
if (p.BoundingBox.Right > max) max = p.BoundingBox.Right;
return max;
}
private static double MinLeft(List<Part> col)
{
var min = double.MaxValue;
foreach (var p in col)
if (p.BoundingBox.Left < min) min = p.BoundingBox.Left;
return min;
}
private static double MinBottom(List<Part> row)
{
var min = double.MaxValue;
foreach (var p in row)
if (p.BoundingBox.Bottom < min) min = p.BoundingBox.Bottom;
return min;
}
}
}

View File

@@ -10,6 +10,12 @@ using System.Threading;
namespace OpenNest.Engine.Fill
{
public class PairFillResult
{
public List<Part> Parts { get; set; } = new List<Part>();
public List<BestFitResult> BestFits { get; set; }
}
/// <summary>
/// Fills a work area using interlocking part pairs from BestFitCache.
/// </summary>
@@ -24,36 +30,40 @@ namespace OpenNest.Engine.Fill
private readonly Size plateSize;
private readonly double partSpacing;
/// <summary>
/// The best-fit results computed during the last Fill call.
/// Available after Fill returns so callers can reuse without recomputing.
/// </summary>
public List<BestFitResult> BestFits { get; private set; }
public PairFiller(Size plateSize, double partSpacing)
{
this.plateSize = plateSize;
this.partSpacing = partSpacing;
}
public List<Part> Fill(NestItem item, Box workArea,
public PairFillResult Fill(NestItem item, Box workArea,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null)
{
BestFits = BestFitCache.GetOrCompute(
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
var candidates = SelectPairCandidates(BestFits, workArea);
Debug.WriteLine($"[PairFiller] Total: {BestFits.Count}, Kept: {BestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
var candidates = SelectPairCandidates(bestFits, workArea);
Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
Debug.WriteLine($"[PairFiller] Plate: {plateSize.Length:F2}x{plateSize.Width:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");
var targetCount = item.Quantity > 0 ? item.Quantity : 0;
var effectiveWorkArea = workArea;
var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
plateNumber, token, progress);
return new PairFillResult { Parts = parts, BestFits = bestFits };
}
private List<Part> EvaluateCandidates(
List<BestFitResult> candidates, Drawing drawing,
Box workArea, int targetCount,
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
{
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
var effectiveWorkArea = workArea;
try
{
@@ -61,34 +71,15 @@ namespace OpenNest.Engine.Fill
{
token.ThrowIfCancellationRequested();
var filled = EvaluateCandidate(candidates[i], item.Drawing, effectiveWorkArea);
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
var score = FillScore.Compute(filled, effectiveWorkArea);
if (filled != null && filled.Count > 0)
if (score > bestScore)
{
var score = FillScore.Compute(filled, effectiveWorkArea);
if (best == null || score > bestScore)
{
best = filled;
bestScore = score;
sinceImproved = 0;
// If we exceeded the target, reduce the work area for
// subsequent candidates by trimming excess parts and
// measuring the tighter bounding box.
if (targetCount > 0 && filled.Count > targetCount)
{
var reduced = ReduceWorkArea(filled, targetCount, workArea);
if (reduced.Area() < effectiveWorkArea.Area())
{
effectiveWorkArea = reduced;
Debug.WriteLine($"[PairFiller] Reduced work area to {effectiveWorkArea.Width:F2}x{effectiveWorkArea.Length:F2} (trimmed to {targetCount + 1} parts)");
}
}
}
else
{
sinceImproved++;
}
best = filled;
bestScore = score;
sinceImproved = 0;
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
}
else
{
@@ -114,6 +105,19 @@ namespace OpenNest.Engine.Fill
return best ?? new List<Part>();
}
private static Box TryReduceWorkArea(List<Part> parts, int targetCount, Box workArea, Box effectiveWorkArea)
{
if (targetCount <= 0 || parts.Count <= targetCount)
return effectiveWorkArea;
var reduced = ReduceWorkArea(parts, targetCount, workArea);
if (reduced.Area() >= effectiveWorkArea.Area())
return effectiveWorkArea;
Debug.WriteLine($"[PairFiller] Reduced work area to {reduced.Width:F2}x{reduced.Length:F2} (trimmed to {targetCount + 1} parts)");
return reduced;
}
/// <summary>
/// Given parts that exceed targetCount, sorts by BoundingBox.Top descending,
/// removes parts from the top until exactly targetCount remain, then returns
@@ -124,12 +128,10 @@ namespace OpenNest.Engine.Fill
if (parts.Count <= targetCount)
return workArea;
// Sort by Top descending — highest parts get trimmed first.
var sorted = parts
.OrderByDescending(p => p.BoundingBox.Top)
.ToList();
// Remove from the top until exactly targetCount remain.
var trimCount = sorted.Count - targetCount;
var remaining = sorted.Skip(trimCount).ToList();
@@ -144,22 +146,23 @@ namespace OpenNest.Engine.Fill
{
var pairParts = candidate.BuildParts(drawing);
var engine = new FillLinear(workArea, partSpacing);
var angles = BuildTilingAngles(candidate);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
}
var p0 = FillHelpers.BuildRotatedPattern(pairParts, 0);
var p90 = FillHelpers.BuildRotatedPattern(pairParts, Angle.HalfPI);
engine.RemainderPatterns = new List<Pattern> { p0, p90 };
// Include the pair's rotating calipers optimal rotation angle
// alongside the hull edge angles for tiling.
private static List<double> BuildTilingAngles(BestFitResult candidate)
{
var angles = new List<double>(candidate.HullAngles);
var optAngle = -candidate.OptimalRotation;
if (!angles.Any(a => a.IsEqualTo(optAngle)))
angles.Add(optAngle);
var optAngle90 = Angle.NormalizeRad(optAngle + Angle.HalfPI);
if (!angles.Any(a => a.IsEqualTo(optAngle90)))
angles.Add(optAngle90);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
return angles;
}
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
@@ -191,7 +194,41 @@ namespace OpenNest.Engine.Fill
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
}
SortByEstimatedCount(top, workArea);
return top;
}
private void SortByEstimatedCount(List<BestFitResult> candidates, Box workArea)
{
var w = workArea.Width;
var l = workArea.Length;
candidates.Sort((a, b) =>
{
var aCount = EstimateTileCount(a, w, l);
var bCount = EstimateTileCount(b, w, l);
if (aCount != bCount)
return bCount.CompareTo(aCount);
return b.Utilization.CompareTo(a.Utilization);
});
}
private int EstimateTileCount(BestFitResult r, double areaW, double areaL)
{
var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL);
var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL);
return System.Math.Max(h, v);
}
private int EstimateCount(double pairW, double pairH, double areaW, double areaL)
{
if (pairW <= 0 || pairH <= 0) return 0;
var cols = (int)((areaW + partSpacing) / (pairW + partSpacing));
var rows = (int)((areaL + partSpacing) / (pairH + partSpacing));
return cols * rows * 2;
}
}
}

View File

@@ -37,76 +37,106 @@ namespace OpenNest.Engine.Fill
return new List<Part>();
var allParts = new List<Part>();
var madeProgress = true;
// Track quantities locally — do not mutate the input NestItem objects.
var localQty = new Dictionary<string, int>();
foreach (var item in items)
localQty[item.Drawing.Name] = item.Quantity;
var localQty = BuildLocalQuantities(items);
while (madeProgress && !token.IsCancellationRequested)
while (!token.IsCancellationRequested)
{
madeProgress = false;
var minRemnantDim = double.MaxValue;
foreach (var item in items)
{
var qty = localQty[item.Drawing.Name];
if (qty <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
var dim = System.Math.Min(bb.Width, bb.Length);
if (dim < minRemnantDim)
minRemnantDim = dim;
}
if (minRemnantDim == double.MaxValue)
var minDim = FindMinItemDimension(items, localQty);
if (minDim == double.MaxValue)
break;
var freeBoxes = finder.FindRemnants(minRemnantDim);
var freeBoxes = finder.FindRemnants(minDim);
if (freeBoxes.Count == 0)
break;
foreach (var item in items)
{
if (token.IsCancellationRequested)
break;
var qty = localQty[item.Drawing.Name];
if (qty == 0)
continue;
var itemBbox = item.Drawing.Program.BoundingBox();
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
foreach (var box in freeBoxes)
{
if (System.Math.Min(box.Width, box.Length) < minItemDim)
continue;
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
var remnantParts = fillFunc(fillItem, box);
if (remnantParts != null && remnantParts.Count > 0)
{
allParts.AddRange(remnantParts);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
foreach (var p in remnantParts)
finder.AddObstacle(p.BoundingBox.Offset(spacing));
madeProgress = true;
break;
}
}
if (madeProgress)
break;
}
if (!TryFillOneItem(items, freeBoxes, localQty, fillFunc, allParts, token))
break;
}
return allParts;
}
private static Dictionary<string, int> BuildLocalQuantities(List<NestItem> items)
{
var localQty = new Dictionary<string, int>(items.Count);
foreach (var item in items)
localQty[item.Drawing.Name] = item.Quantity;
return localQty;
}
private static double FindMinItemDimension(List<NestItem> items, Dictionary<string, int> localQty)
{
var minDim = double.MaxValue;
foreach (var item in items)
{
if (localQty[item.Drawing.Name] <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
var dim = System.Math.Min(bb.Width, bb.Length);
if (dim < minDim)
minDim = dim;
}
return minDim;
}
private bool TryFillOneItem(
List<NestItem> items,
List<Box> freeBoxes,
Dictionary<string, int> localQty,
Func<NestItem, Box, List<Part>> fillFunc,
List<Part> allParts,
CancellationToken token)
{
foreach (var item in items)
{
if (token.IsCancellationRequested)
return false;
var qty = localQty[item.Drawing.Name];
if (qty <= 0)
continue;
var placed = TryFillInRemnants(item, qty, freeBoxes, fillFunc);
if (placed == null)
continue;
allParts.AddRange(placed);
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
foreach (var p in placed)
finder.AddObstacle(p.BoundingBox.Offset(spacing));
return true;
}
return false;
}
private static List<Part> TryFillInRemnants(
NestItem item,
int qty,
List<Box> freeBoxes,
Func<NestItem, Box, List<Part>> fillFunc)
{
var itemBbox = item.Drawing.Program.BoundingBox();
foreach (var box in freeBoxes)
{
var fitsNormal = box.Width >= itemBbox.Width && box.Length >= itemBbox.Length;
var fitsRotated = box.Width >= itemBbox.Length && box.Length >= itemBbox.Width;
if (!fitsNormal && !fitsRotated)
continue;
var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
var parts = fillFunc(fillItem, box);
if (parts != null && parts.Count > 0)
return parts;
}
return null;
}
}
}

View File

@@ -15,9 +15,9 @@ namespace OpenNest.Engine.Strategies
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
context.SharedState["BestFits"] = filler.BestFits;
context.SharedState["BestFits"] = result.BestFits;
return result;
return result.Parts;
}
}
}

View File

@@ -77,12 +77,12 @@ namespace OpenNest
// Phase 1: Iterative shrink-fill for multi-quantity items.
if (fillItems.Count > 0)
{
// Inner fills are silent — ShrinkFiller manages progress reporting
// to avoid overlapping layouts from per-angle/per-strategy reports.
// Pass progress through so the UI shows intermediate results
// during the initial BestFitCache computation and fill phases.
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(ni, b, null, token);
return inner.Fill(ni, b, progress, token);
};
var shrinkResult = IterativeShrinkFiller.Fill(
@@ -91,6 +91,14 @@ namespace OpenNest
allParts.AddRange(shrinkResult.Parts);
// Compact placed parts toward the origin to close gaps.
if (allParts.Count > 1)
{
var noObstacles = new List<Part>();
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left);
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down);
}
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
}

View File

@@ -24,11 +24,10 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);
var parts = filler.Fill(item, workArea);
var result = filler.Fill(item, workArea);
Assert.NotNull(parts);
// Pair filling may or may not find interlocking pairs for rectangles,
// but should return a non-null list.
Assert.NotNull(result.Parts);
Assert.NotNull(result.BestFits);
}
[Fact]
@@ -39,10 +38,10 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
var workArea = new Box(0, 0, 10, 10);
var parts = filler.Fill(item, workArea);
var result = filler.Fill(item, workArea);
Assert.NotNull(parts);
Assert.Empty(parts);
Assert.NotNull(result.Parts);
Assert.Empty(result.Parts);
}
[Fact]
@@ -56,9 +55,8 @@ public class PairFillerTests
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var workArea = new Box(0, 0, 120, 60);
var parts = filler.Fill(item, workArea, token: cts.Token);
var result = filler.Fill(item, workArea, token: cts.Token);
// Should return empty or partial — not throw
Assert.NotNull(parts);
Assert.NotNull(result.Parts);
}
}