Strategies and fillers previously called NestEngineBase.ReportProgress directly, each constructing ProgressReport structs with phase, plate number, and work area manually. Some strategies (RectBestFit) reported nothing at all. This made progress updates inconsistent and flakey. Add FillContext.ReportProgress(parts, description) as the single standard method for intermediate progress. RunPipeline sets ActivePhase before each strategy, and the context handles common fields. Lower-level fillers (PairFiller, FillExtents, StripeFiller) now accept an Action<List<Part>, string> callback instead of raw IProgress, removing their coupling to NestEngineBase and ProgressReport. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
420 lines
17 KiB
C#
420 lines
17 KiB
C#
using OpenNest.Engine.BestFit;
|
|
using OpenNest.Engine.Strategies;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using OpenNest.Engine;
|
|
|
|
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>
|
|
public class PairFiller
|
|
{
|
|
private const int MaxTopCandidates = 50;
|
|
private const int MaxStripCandidates = 100;
|
|
private const double MinStripUtilization = 0.3;
|
|
private const int EarlyExitMinTried = 10;
|
|
private const int EarlyExitStaleLimit = 10;
|
|
|
|
private readonly Plate plate;
|
|
private readonly Size plateSize;
|
|
private readonly double partSpacing;
|
|
private readonly IFillComparer comparer;
|
|
private readonly GridDedup dedup;
|
|
|
|
public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null)
|
|
{
|
|
this.plate = plate;
|
|
this.plateSize = plate.Size;
|
|
this.partSpacing = plate.PartSpacing;
|
|
this.comparer = comparer ?? new DefaultFillComparer();
|
|
this.dedup = dedup ?? new GridDedup();
|
|
}
|
|
|
|
public PairFillResult Fill(NestItem item, Box workArea,
|
|
CancellationToken token = default,
|
|
Action<List<Part>, string> reportProgress = null)
|
|
{
|
|
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}");
|
|
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 parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
|
|
token, reportProgress);
|
|
|
|
return new PairFillResult { Parts = parts, BestFits = bestFits };
|
|
}
|
|
|
|
private List<Part> EvaluateCandidates(
|
|
List<BestFitResult> candidates, Drawing drawing,
|
|
Box workArea, int targetCount,
|
|
CancellationToken token, Action<List<Part>, string> reportProgress)
|
|
{
|
|
List<Part> best = null;
|
|
var sinceImproved = 0;
|
|
var effectiveWorkArea = workArea;
|
|
var batchSize = System.Math.Max(2, Environment.ProcessorCount);
|
|
|
|
var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0;
|
|
var partBox = drawing.Program.BoundingBox();
|
|
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
|
|
|
|
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
|
try
|
|
{
|
|
for (var batchStart = 0; batchStart < candidates.Count; batchStart += batchSize)
|
|
{
|
|
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<Part>[batchCount];
|
|
Parallel.For(0, batchCount,
|
|
new ParallelOptions { CancellationToken = token },
|
|
j =>
|
|
{
|
|
results[j] = EvaluateCandidate(
|
|
candidates[batchStart + j], drawing, batchWorkArea,
|
|
minCountToBeat, 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");
|
|
}
|
|
|
|
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
|
{
|
|
Debug.WriteLine($"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
|
|
}
|
|
finally
|
|
{
|
|
FillStrategyRegistry.SetEnabled(null);
|
|
}
|
|
|
|
Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts");
|
|
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
|
|
/// the Top of the remaining parts as the new work area height to beat.
|
|
/// </summary>
|
|
private static Box ReduceWorkArea(List<Part> parts, int targetCount, Box workArea)
|
|
{
|
|
if (parts.Count <= targetCount)
|
|
return workArea;
|
|
|
|
var sorted = parts
|
|
.OrderByDescending(p => p.BoundingBox.Top)
|
|
.ToList();
|
|
|
|
var trimCount = sorted.Count - targetCount;
|
|
var remaining = sorted.Skip(trimCount).ToList();
|
|
|
|
var newTop = remaining.Max(p => p.BoundingBox.Top);
|
|
|
|
return new Box(workArea.X, workArea.Y,
|
|
workArea.Length,
|
|
System.Math.Min(newTop - workArea.Y, workArea.Width));
|
|
}
|
|
|
|
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
|
|
Box workArea, int minCountToBeat, double maxUtilization, double partArea,
|
|
CancellationToken token)
|
|
{
|
|
var pairParts = candidate.BuildParts(drawing);
|
|
var angles = BuildTilingAngles(candidate);
|
|
|
|
// Phase 1: evaluate all grids (fast)
|
|
var grids = new List<(List<Part> Parts, NestDirection Dir)>();
|
|
foreach (var angle in angles)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
|
|
if (pattern.Parts.Count == 0)
|
|
continue;
|
|
|
|
var engine = new FillLinear(workArea, partSpacing) { Label = "Pairs" };
|
|
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
|
{
|
|
if (!dedup.TryAdd(pattern.BoundingBox, workArea, dir))
|
|
continue;
|
|
|
|
var gridParts = engine.Fill(pattern, dir);
|
|
if (gridParts != null && gridParts.Count > 0)
|
|
grids.Add((gridParts, dir));
|
|
}
|
|
}
|
|
|
|
if (grids.Count == 0)
|
|
return null;
|
|
|
|
// Sort by count descending so we try the best grids first
|
|
grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count));
|
|
|
|
// Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2
|
|
if (minCountToBeat > 0)
|
|
{
|
|
var topCount = grids[0].Parts.Count;
|
|
var optimisticRemnant = EstimateRemnantUpperBound(
|
|
grids[0].Parts, workArea, maxUtilization, partArea);
|
|
if (topCount + optimisticRemnant <= minCountToBeat)
|
|
{
|
|
Debug.WriteLine($"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Phase 2: try remnant for each grid, skip if grid is too far behind
|
|
List<Part> best = null;
|
|
|
|
foreach (var (gridParts, dir) in grids)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
// If this grid + max possible remnant can't beat current best, skip
|
|
if (best != null)
|
|
{
|
|
var remnantBound = EstimateRemnantUpperBound(
|
|
gridParts, workArea, maxUtilization, partArea);
|
|
if (gridParts.Count + remnantBound <= best.Count)
|
|
break; // sorted descending, so remaining are even smaller
|
|
}
|
|
|
|
var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
|
|
List<Part> total;
|
|
if (remnantParts != null && remnantParts.Count > 0)
|
|
{
|
|
total = new List<Part>(gridParts.Count + remnantParts.Count);
|
|
total.AddRange(gridParts);
|
|
total.AddRange(remnantParts);
|
|
}
|
|
else
|
|
{
|
|
total = gridParts;
|
|
}
|
|
|
|
if (comparer.IsBetter(total, best, workArea))
|
|
best = total;
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private int EstimateRemnantUpperBound(List<Part> gridParts, Box workArea,
|
|
double maxUtilization, double partArea)
|
|
{
|
|
var gridBox = ((IEnumerable<IBoundable>)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<Part> FillRemnant(List<Part> gridParts, Drawing drawing,
|
|
Box workArea, CancellationToken token)
|
|
{
|
|
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
|
|
var partBox = drawing.Program.BoundingBox();
|
|
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
|
|
|
|
List<Part> 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<Part> 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<Part> 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;
|
|
}
|
|
|
|
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 angles;
|
|
}
|
|
|
|
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> 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<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;
|
|
}
|
|
}
|
|
}
|