diff --git a/OpenNest.Engine/Fill/PairCandidateSelector.cs b/OpenNest.Engine/Fill/PairCandidateSelector.cs
new file mode 100644
index 0000000..5031dab
--- /dev/null
+++ b/OpenNest.Engine/Fill/PairCandidateSelector.cs
@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using OpenNest.Engine.BestFit;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Fill
+{
+ ///
+ /// Selects and ranks BestFitCache pair candidates for a work area, favoring
+ /// narrow "strip" candidates when the work area is much smaller than the plate.
+ ///
+ public class PairCandidateSelector
+ {
+ private const int MaxTopCandidates = 50;
+ private const int MaxStripCandidates = 100;
+ private const double MinStripUtilization = 0.3;
+
+ private readonly Size plateSize;
+ private readonly double partSpacing;
+
+ public PairCandidateSelector(Size plateSize, double partSpacing)
+ {
+ this.plateSize = plateSize;
+ this.partSpacing = partSpacing;
+ }
+
+ public List Select(List bestFits, Box workArea)
+ {
+ var kept = bestFits.Where(r => r.Keep).ToList();
+
+ var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
+
+ if (workShortSide < plateShortSide * 0.5)
+ {
+ // Strip mode: prioritize candidates that fit the narrow dimension.
+ var stripCandidates = kept.Where(r =>
+ r.ShortestSide <= workShortSide + Tolerance.Epsilon
+ && r.Utilization >= MinStripUtilization
+ )
+ .ToList();
+
+ SortByEstimatedCount(stripCandidates, workArea);
+
+ var top = stripCandidates.Take(MaxStripCandidates).ToList();
+
+ Debug.WriteLine(
+ $"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"
+ );
+ return top;
+ }
+
+ var result = kept.Take(MaxTopCandidates).ToList();
+ SortByEstimatedCount(result, workArea);
+
+ return result;
+ }
+
+ private void SortByEstimatedCount(List 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;
+ }
+ }
+}
diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs
index 8181e3b..fe3577b 100644
--- a/OpenNest.Engine/Fill/PairFiller.cs
+++ b/OpenNest.Engine/Fill/PairFiller.cs
@@ -12,29 +12,78 @@ using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
+ ///
+ /// Defines the
+ ///
public class PairFillResult
{
+ ///
+ /// Gets or sets the Parts
+ ///
public List Parts { get; set; } = new List();
+
+ ///
+ /// Gets or sets the BestFits
+ ///
public List BestFits { get; set; }
}
///
- /// Fills a work area using interlocking part pairs from BestFitCache.
+ /// Fills a work area using interlocking part pairs from BestFitCache
///
public class PairFiller
{
- private const int MaxTopCandidates = 50;
- private const int MaxStripCandidates = 100;
- private const double MinStripUtilization = 0.3;
+ ///
+ /// Defines the EarlyExitMinTried
+ ///
private const int EarlyExitMinTried = 10;
+
+ ///
+ /// Defines the EarlyExitStaleLimit
+ ///
private const int EarlyExitStaleLimit = 10;
+ ///
+ /// Defines the plate
+ ///
private readonly Plate plate;
+
+ ///
+ /// Defines the plateSize
+ ///
private readonly Size plateSize;
+
+ ///
+ /// Defines the partSpacing
+ ///
private readonly double partSpacing;
+
+ ///
+ /// Defines the comparer
+ ///
private readonly IFillComparer comparer;
+
+ ///
+ /// Defines the dedup
+ ///
private readonly GridDedup dedup;
+ ///
+ /// Defines the candidateSelector
+ ///
+ private readonly PairCandidateSelector candidateSelector;
+
+ ///
+ /// Defines the remnantFiller
+ ///
+ private readonly PairRemnantFiller remnantFiller;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The plate
+ /// The comparer
+ /// The dedup
public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null)
{
this.plate = plate;
@@ -42,8 +91,18 @@ namespace OpenNest.Engine.Fill
this.partSpacing = plate.PartSpacing;
this.comparer = comparer ?? new DefaultFillComparer();
this.dedup = dedup ?? new GridDedup();
+ this.candidateSelector = new PairCandidateSelector(plateSize, partSpacing);
+ this.remnantFiller = new PairRemnantFiller(partSpacing, this.comparer);
}
+ ///
+ /// The Fill
+ ///
+ /// The item
+ /// The workArea
+ /// The token
+ /// The reportProgress
+ /// The
public PairFillResult Fill(
NestItem item,
Box workArea,
@@ -58,7 +117,7 @@ namespace OpenNest.Engine.Fill
partSpacing
);
- var candidates = SelectPairCandidates(bestFits, workArea);
+ var candidates = candidateSelector.Select(bestFits, workArea);
Debug.WriteLine(
$"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"
);
@@ -79,6 +138,16 @@ namespace OpenNest.Engine.Fill
return new PairFillResult { Parts = parts, BestFits = bestFits };
}
+ ///
+ /// The EvaluateCandidates
+ ///
+ /// The candidates
+ /// The drawing
+ /// The workArea
+ /// The targetCount
+ /// The token
+ /// The reportProgress
+ /// The
private List EvaluateCandidates(
List candidates,
Drawing drawing,
@@ -104,52 +173,29 @@ namespace OpenNest.Engine.Fill
token.ThrowIfCancellationRequested();
var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count);
- var batchCount = batchEnd - batchStart;
- var batchWorkArea = effectiveWorkArea;
- var minCountToBeat = best?.Count ?? 0;
-
- var results = new List[batchCount];
- Parallel.For(
- 0,
- batchCount,
- new ParallelOptions { CancellationToken = token },
- j =>
- {
- results[j] = EvaluateCandidate(
- candidates[batchStart + j],
- drawing,
- batchWorkArea,
- minCountToBeat,
- maxUtilization,
- partArea,
- token
- );
- }
+ var results = EvaluateBatch(
+ candidates,
+ drawing,
+ effectiveWorkArea,
+ batchStart,
+ batchEnd,
+ best?.Count ?? 0,
+ maxUtilization,
+ partArea,
+ token
);
- for (var j = 0; j < batchCount; j++)
- {
- if (comparer.IsBetter(results[j], best, effectiveWorkArea))
- {
- best = results[j];
- sinceImproved = 0;
- effectiveWorkArea = TryReduceWorkArea(
- best,
- targetCount,
- workArea,
- effectiveWorkArea
- );
- }
- else
- {
- sinceImproved++;
- }
-
- reportProgress?.Invoke(
- best,
- $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts"
- );
- }
+ (best, effectiveWorkArea, sinceImproved) = ProcessBatchResults(
+ results,
+ best,
+ sinceImproved,
+ workArea,
+ effectiveWorkArea,
+ targetCount,
+ candidates.Count,
+ batchStart,
+ reportProgress
+ );
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
{
@@ -169,6 +215,82 @@ namespace OpenNest.Engine.Fill
return best ?? new List();
}
+ private List[] EvaluateBatch(
+ List candidates,
+ Drawing drawing,
+ Box workArea,
+ int batchStart,
+ int batchEnd,
+ int minCountToBeat,
+ double maxUtilization,
+ double partArea,
+ CancellationToken token
+ )
+ {
+ var batchCount = batchEnd - batchStart;
+ var results = new List[batchCount];
+ Parallel.For(
+ 0,
+ batchCount,
+ new ParallelOptions { CancellationToken = token },
+ j =>
+ {
+ results[j] = EvaluateCandidate(
+ candidates[batchStart + j],
+ drawing,
+ workArea,
+ minCountToBeat,
+ maxUtilization,
+ partArea,
+ token
+ );
+ }
+ );
+ return results;
+ }
+
+ private (List Best, Box EffectiveWorkArea, int SinceImproved) ProcessBatchResults(
+ List[] results,
+ List best,
+ int sinceImproved,
+ Box workArea,
+ Box effectiveWorkArea,
+ int targetCount,
+ int totalCandidates,
+ int batchStart,
+ Action, string> reportProgress
+ )
+ {
+ for (var j = 0; j < results.Length; j++)
+ {
+ if (comparer.IsBetter(results[j], best, effectiveWorkArea))
+ {
+ best = results[j];
+ sinceImproved = 0;
+ effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea);
+ }
+ else
+ {
+ sinceImproved++;
+ }
+
+ reportProgress?.Invoke(
+ best,
+ $"Pairs: {batchStart + j + 1}/{totalCandidates} candidates, best = {best?.Count ?? 0} parts"
+ );
+ }
+
+ return (best, effectiveWorkArea, sinceImproved);
+ }
+
+ ///
+ /// The TryReduceWorkArea
+ ///
+ /// The parts
+ /// The targetCount
+ /// The workArea
+ /// The effectiveWorkArea
+ /// The
private static Box TryReduceWorkArea(
List parts,
int targetCount,
@@ -192,8 +314,12 @@ namespace OpenNest.Engine.Fill
///
/// Given parts that exceed targetCount, sorts by BoundingBox.Top descending,
/// removes parts from the top until exactly targetCount remain, then returns
- /// the Top of the remaining parts as the new work area height to beat.
+ /// the Top of the remaining parts as the new work area height to beat
///
+ /// The parts
+ /// The targetCount
+ /// The workArea
+ /// The
private static Box ReduceWorkArea(List parts, int targetCount, Box workArea)
{
if (parts.Count <= targetCount)
@@ -214,6 +340,17 @@ namespace OpenNest.Engine.Fill
);
}
+ ///
+ /// The EvaluateCandidate
+ ///
+ /// The candidate
+ /// The drawing
+ /// The workArea
+ /// The minCountToBeat
+ /// The maxUtilization
+ /// The partArea
+ /// The token
+ /// The
private List EvaluateCandidate(
BestFitResult candidate,
Drawing drawing,
@@ -258,7 +395,7 @@ namespace OpenNest.Engine.Fill
if (minCountToBeat > 0)
{
var topCount = grids[0].Parts.Count;
- var optimisticRemnant = EstimateRemnantUpperBound(
+ var optimisticRemnant = remnantFiller.EstimateUpperBound(
grids[0].Parts,
workArea,
maxUtilization,
@@ -283,7 +420,7 @@ namespace OpenNest.Engine.Fill
// If this grid + max possible remnant can't beat current best, skip
if (best != null)
{
- var remnantBound = EstimateRemnantUpperBound(
+ var remnantBound = remnantFiller.EstimateUpperBound(
gridParts,
workArea,
maxUtilization,
@@ -293,7 +430,7 @@ namespace OpenNest.Engine.Fill
break; // sorted descending, so remaining are even smaller
}
- var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
+ var remnantParts = remnantFiller.Fill(gridParts, drawing, workArea, token);
List total;
if (remnantParts != null && remnantParts.Count > 0)
{
@@ -313,104 +450,11 @@ namespace OpenNest.Engine.Fill
return best;
}
- private int EstimateRemnantUpperBound(
- List gridParts,
- Box workArea,
- double maxUtilization,
- double partArea
- )
- {
- var gridBox = ((IEnumerable)gridParts).GetBoundingBox();
-
- // L-shaped remnant: top strip (full width) + right strip (grid height only)
- var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
- var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
-
- var topArea = workArea.Length * topHeight;
- var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width);
- var remnantArea = topArea + rightArea;
-
- return (int)(remnantArea * maxUtilization / partArea) + 1;
- }
-
- private List FillRemnant(
- List gridParts,
- Drawing drawing,
- Box workArea,
- CancellationToken token
- )
- {
- var gridBox = ((IEnumerable)gridParts).GetBoundingBox();
- var partBox = drawing.Program.BoundingBox();
- var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
-
- List bestRemnant = null;
-
- // Try top remnant (full width, above grid)
- var topY = gridBox.Top + partSpacing;
- var topLength = workArea.Top - topY;
- if (topLength >= minDim)
- {
- var topBox = new Box(workArea.X, topY, workArea.Length, topLength);
- var parts = FillRemnantBox(drawing, topBox, token);
- if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
- bestRemnant = parts;
- }
-
- // Try right remnant (full height, right of grid)
- var rightX = gridBox.Right + partSpacing;
- var rightWidth = workArea.Right - rightX;
- if (rightWidth >= minDim)
- {
- var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width);
- var parts = FillRemnantBox(drawing, rightBox, token);
- if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
- bestRemnant = parts;
- }
-
- return bestRemnant;
- }
-
- private List FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
- {
- var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
- if (cachedResult != null)
- {
- Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
- return cachedResult;
- }
-
- var filler = new FillLinear(remnantBox, partSpacing) { Label = "Pairs-Remnant" };
- List parts = null;
-
- foreach (var angle in new[] { 0.0, Angle.HalfPI })
- {
- token.ThrowIfCancellationRequested();
- var result = FillHelpers.FillWithDirectionPreference(
- dir => filler.Fill(drawing, angle, dir),
- null,
- comparer,
- remnantBox
- );
-
- if (result != null && result.Count > (parts?.Count ?? 0))
- parts = result;
- }
-
- Debug.WriteLine(
- $"[PairFiller] Remnant: {parts?.Count ?? 0} parts in "
- + $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"
- );
-
- if (parts != null && parts.Count > 0)
- {
- FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
- return parts;
- }
-
- return null;
- }
-
+ ///
+ /// The BuildTilingAngles
+ ///
+ /// The candidate
+ /// The
private static List BuildTilingAngles(BestFitResult candidate)
{
var angles = new List(candidate.HullAngles);
@@ -425,72 +469,5 @@ namespace OpenNest.Engine.Fill
return angles;
}
-
- private List SelectPairCandidates(List bestFits, Box workArea)
- {
- var kept = bestFits.Where(r => r.Keep).ToList();
-
- var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
- var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
-
- if (workShortSide < plateShortSide * 0.5)
- {
- // Strip mode: prioritize candidates that fit the narrow dimension.
- var stripCandidates = kept.Where(r =>
- r.ShortestSide <= workShortSide + Tolerance.Epsilon
- && r.Utilization >= MinStripUtilization
- )
- .ToList();
-
- SortByEstimatedCount(stripCandidates, workArea);
-
- var top = stripCandidates.Take(MaxStripCandidates).ToList();
-
- Debug.WriteLine(
- $"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"
- );
- return top;
- }
-
- var result = kept.Take(MaxTopCandidates).ToList();
- SortByEstimatedCount(result, workArea);
-
- return result;
- }
-
- private void SortByEstimatedCount(List 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;
- }
}
}
diff --git a/OpenNest.Engine/Fill/PairRemnantFiller.cs b/OpenNest.Engine/Fill/PairRemnantFiller.cs
new file mode 100644
index 0000000..4ae1791
--- /dev/null
+++ b/OpenNest.Engine/Fill/PairRemnantFiller.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using OpenNest.Engine.Strategies;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Fill
+{
+ ///
+ /// Fills the leftover L-shaped area (top strip + right strip) around a pair grid
+ /// with the same drawing, caching results by drawing/box/spacing.
+ ///
+ public class PairRemnantFiller
+ {
+ private readonly double partSpacing;
+ private readonly IFillComparer comparer;
+
+ public PairRemnantFiller(double partSpacing, IFillComparer comparer)
+ {
+ this.partSpacing = partSpacing;
+ this.comparer = comparer;
+ }
+
+ public int EstimateUpperBound(
+ List gridParts,
+ Box workArea,
+ double maxUtilization,
+ double partArea
+ )
+ {
+ var gridBox = ((IEnumerable)gridParts).GetBoundingBox();
+
+ // L-shaped remnant: top strip (full width) + right strip (grid height only)
+ var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
+ var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
+
+ var topArea = workArea.Length * topHeight;
+ var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width);
+ var remnantArea = topArea + rightArea;
+
+ return (int)(remnantArea * maxUtilization / partArea) + 1;
+ }
+
+ public List Fill(
+ List gridParts,
+ Drawing drawing,
+ Box workArea,
+ CancellationToken token
+ )
+ {
+ var gridBox = ((IEnumerable)gridParts).GetBoundingBox();
+ var partBox = drawing.Program.BoundingBox();
+ var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
+
+ List bestRemnant = null;
+
+ // Try top remnant (full width, above grid)
+ var topY = gridBox.Top + partSpacing;
+ var topLength = workArea.Top - topY;
+ if (topLength >= minDim)
+ {
+ var topBox = new Box(workArea.X, topY, workArea.Length, topLength);
+ var parts = FillRemnantBox(drawing, topBox, token);
+ if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
+ bestRemnant = parts;
+ }
+
+ // Try right remnant (full height, right of grid)
+ var rightX = gridBox.Right + partSpacing;
+ var rightWidth = workArea.Right - rightX;
+ if (rightWidth >= minDim)
+ {
+ var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width);
+ var parts = FillRemnantBox(drawing, rightBox, token);
+ if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
+ bestRemnant = parts;
+ }
+
+ return bestRemnant;
+ }
+
+ private List FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
+ {
+ var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
+ if (cachedResult != null)
+ {
+ Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
+ return cachedResult;
+ }
+
+ var filler = new FillLinear(remnantBox, partSpacing) { Label = "Pairs-Remnant" };
+ List parts = null;
+
+ foreach (var angle in new[] { 0.0, Angle.HalfPI })
+ {
+ token.ThrowIfCancellationRequested();
+ var result = FillHelpers.FillWithDirectionPreference(
+ dir => filler.Fill(drawing, angle, dir),
+ null,
+ comparer,
+ remnantBox
+ );
+
+ if (result != null && result.Count > (parts?.Count ?? 0))
+ parts = result;
+ }
+
+ Debug.WriteLine(
+ $"[PairFiller] Remnant: {parts?.Count ?? 0} parts in "
+ + $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"
+ );
+
+ if (parts != null && parts.Count > 0)
+ {
+ FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
+ return parts;
+ }
+
+ return null;
+ }
+ }
+}