FillExtents falls back to the unadjusted column when iterative pair adjustment shifts parts enough to cause genuine overlap. StripeFiller rejects grid results where bounding boxes overlap, which can occur when angle convergence produces slightly off-axis rotations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
513 lines
18 KiB
C#
513 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using OpenNest.Engine;
|
|
using OpenNest.Engine.BestFit;
|
|
using OpenNest.Engine.Strategies;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
using System.Diagnostics;
|
|
|
|
namespace OpenNest.Engine.Fill;
|
|
|
|
public class StripeFiller
|
|
{
|
|
private const int MaxPairCandidates = 5;
|
|
private const int MaxConvergenceIterations = 20;
|
|
private const int AngleSamples = 36;
|
|
|
|
private readonly FillContext _context;
|
|
private readonly NestDirection _primaryAxis;
|
|
private readonly IFillComparer _comparer;
|
|
private readonly GridDedup _dedup;
|
|
|
|
/// <summary>
|
|
/// When true, only complete stripes are placed — no partial rows/columns.
|
|
/// </summary>
|
|
public bool CompleteStripesOnly { get; set; }
|
|
|
|
/// <summary>
|
|
/// Factory to create the engine used for filling the remnant strip.
|
|
/// Defaults to NestEngineRegistry.Create (uses the user's selected engine).
|
|
/// </summary>
|
|
public Func<Plate, NestEngineBase> CreateRemnantEngine { get; set; }
|
|
= NestEngineRegistry.Create;
|
|
|
|
public StripeFiller(FillContext context, NestDirection primaryAxis)
|
|
{
|
|
_context = context;
|
|
_primaryAxis = primaryAxis;
|
|
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
|
_dedup = GridDedup.GetOrCreate(context.SharedState);
|
|
}
|
|
|
|
public List<Part> Fill()
|
|
{
|
|
var bestFits = GetPairCandidates();
|
|
if (bestFits.Count == 0)
|
|
return new List<Part>();
|
|
|
|
var workArea = _context.WorkArea;
|
|
var spacing = _context.Plate.PartSpacing;
|
|
var drawing = _context.Item.Drawing;
|
|
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
|
|
|
|
List<Part> bestParts = null;
|
|
|
|
for (var i = 0; i < bestFits.Count; i++)
|
|
{
|
|
_context.Token.ThrowIfCancellationRequested();
|
|
|
|
var candidate = bestFits[i];
|
|
var pairParts = candidate.BuildParts(drawing);
|
|
|
|
foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
|
{
|
|
var perpAxis = axis == NestDirection.Horizontal
|
|
? NestDirection.Vertical : NestDirection.Horizontal;
|
|
var sheetSpan = GetDimension(workArea, axis);
|
|
var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
|
|
|
|
var expandResult = ConvergeStripeAngle(
|
|
pairParts, sheetSpan, spacing, axis, _context.Token);
|
|
var shrinkResult = ConvergeStripeAngleShrink(
|
|
pairParts, sheetSpan, spacing, axis, _context.Token);
|
|
|
|
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
|
|
{
|
|
if (count <= 0)
|
|
continue;
|
|
|
|
var result = BuildGrid(pairParts, angle, axis, perpAxis);
|
|
|
|
if (result == null || result.Count == 0)
|
|
continue;
|
|
|
|
Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " +
|
|
$"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " +
|
|
$"grid={result.Count} parts");
|
|
|
|
if (_comparer.IsBetter(result, bestParts, workArea))
|
|
{
|
|
bestParts = result;
|
|
}
|
|
}
|
|
}
|
|
|
|
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
|
|
{
|
|
Phase = NestPhase.Custom,
|
|
PlateNumber = _context.PlateNumber,
|
|
Parts = bestParts,
|
|
WorkArea = workArea,
|
|
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
|
|
});
|
|
}
|
|
|
|
return bestParts ?? new List<Part>();
|
|
}
|
|
|
|
private List<Part> BuildGrid(List<Part> pairParts, double angle,
|
|
NestDirection primaryAxis, NestDirection perpAxis)
|
|
{
|
|
var workArea = _context.WorkArea;
|
|
var spacing = _context.Plate.PartSpacing;
|
|
|
|
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
|
|
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
|
|
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
|
|
|
|
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
|
|
return null;
|
|
|
|
var stripeEngine = new FillLinear(stripeBox, spacing);
|
|
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
|
|
|
|
if (stripeParts == null || stripeParts.Count == 0)
|
|
return null;
|
|
|
|
var partsPerStripe = stripeParts.Count;
|
|
|
|
Debug.WriteLine($"[StripeFiller] Stripe: {partsPerStripe} parts, " +
|
|
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
|
|
|
|
var stripePattern = new Pattern();
|
|
stripePattern.Parts.AddRange(stripeParts);
|
|
stripePattern.UpdateBounds();
|
|
|
|
var gridEngine = new FillLinear(workArea, spacing);
|
|
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
|
|
|
|
if (gridParts == null || gridParts.Count == 0)
|
|
return null;
|
|
|
|
if (CompleteStripesOnly)
|
|
{
|
|
var completeCount = gridParts.Count / partsPerStripe * partsPerStripe;
|
|
if (completeCount < gridParts.Count)
|
|
{
|
|
Debug.WriteLine($"[StripeFiller] CompleteOnly: {gridParts.Count} → {completeCount} " +
|
|
$"(dropped {gridParts.Count - completeCount} partial)");
|
|
gridParts = gridParts.GetRange(0, completeCount);
|
|
}
|
|
}
|
|
|
|
Debug.WriteLine($"[StripeFiller] Grid: {gridParts.Count} parts");
|
|
|
|
if (gridParts.Count == 0)
|
|
return null;
|
|
|
|
// Reject results where bounding boxes overlap — the angle convergence
|
|
// can produce slightly off-axis rotations where FillLinear's copy
|
|
// distance calculation doesn't fully account for the rotated geometry.
|
|
if (HasOverlappingParts(gridParts))
|
|
{
|
|
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
|
|
return null;
|
|
}
|
|
|
|
var allParts = new List<Part>(gridParts);
|
|
|
|
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
|
if (remnantParts != null)
|
|
{
|
|
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
|
|
allParts.AddRange(remnantParts);
|
|
}
|
|
|
|
return allParts;
|
|
}
|
|
|
|
private List<BestFitResult> GetPairCandidates()
|
|
{
|
|
List<BestFitResult> bestFits;
|
|
|
|
if (_context.SharedState.TryGetValue("BestFits", out var cached))
|
|
bestFits = (List<BestFitResult>)cached;
|
|
else
|
|
bestFits = BestFitCache.GetOrCompute(
|
|
_context.Item.Drawing,
|
|
_context.Plate.Size.Length,
|
|
_context.Plate.Size.Width,
|
|
_context.Plate.PartSpacing);
|
|
|
|
return bestFits
|
|
.Where(r => r.Keep)
|
|
.Take(MaxPairCandidates)
|
|
.ToList();
|
|
}
|
|
|
|
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
|
|
{
|
|
return primaryAxis == NestDirection.Horizontal
|
|
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
|
|
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
|
|
}
|
|
|
|
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
|
|
{
|
|
var workArea = _context.WorkArea;
|
|
var spacing = _context.Plate.PartSpacing;
|
|
var drawing = _context.Item.Drawing;
|
|
|
|
var gridBox = gridParts.GetBoundingBox();
|
|
var minDim = System.Math.Min(
|
|
drawing.Program.BoundingBox().Width,
|
|
drawing.Program.BoundingBox().Length);
|
|
|
|
Box remnantBox;
|
|
|
|
if (primaryAxis == NestDirection.Horizontal)
|
|
{
|
|
var remnantY = gridBox.Top + spacing;
|
|
var remnantLength = workArea.Top - remnantY;
|
|
if (remnantLength < minDim)
|
|
return null;
|
|
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
|
|
}
|
|
else
|
|
{
|
|
var remnantX = gridBox.Right + spacing;
|
|
var remnantWidth = workArea.Right - remnantX;
|
|
if (remnantWidth < minDim)
|
|
return null;
|
|
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
|
|
}
|
|
|
|
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
|
|
|
|
var cachedResult = FillResultCache.Get(drawing, remnantBox, spacing);
|
|
if (cachedResult != null)
|
|
{
|
|
Debug.WriteLine($"[StripeFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
|
|
return cachedResult;
|
|
}
|
|
|
|
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
|
try
|
|
{
|
|
var engine = CreateRemnantEngine(_context.Plate);
|
|
var item = new NestItem { Drawing = drawing };
|
|
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
|
|
|
|
Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
|
|
$"winner={engine.WinnerPhase}");
|
|
|
|
if (parts != null && parts.Count > 0)
|
|
{
|
|
FillResultCache.Store(drawing, remnantBox, spacing, parts);
|
|
return parts;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
FillStrategyRegistry.SetEnabled(null);
|
|
}
|
|
}
|
|
|
|
public static double FindAngleForTargetSpan(
|
|
List<Part> patternParts, double targetSpan, NestDirection axis)
|
|
{
|
|
var bestAngle = 0.0;
|
|
var bestDiff = double.MaxValue;
|
|
var samples = new (double angle, double span)[AngleSamples + 1];
|
|
|
|
for (var i = 0; i <= AngleSamples; i++)
|
|
{
|
|
var angle = i * Angle.HalfPI / AngleSamples;
|
|
var span = GetRotatedSpan(patternParts, angle, axis);
|
|
samples[i] = (angle, span);
|
|
|
|
var diff = System.Math.Abs(span - targetSpan);
|
|
if (diff < bestDiff)
|
|
{
|
|
bestDiff = diff;
|
|
bestAngle = angle;
|
|
}
|
|
}
|
|
|
|
if (bestDiff < Tolerance.Epsilon)
|
|
return bestAngle;
|
|
|
|
for (var i = 0; i < samples.Length - 1; i++)
|
|
{
|
|
var (a1, s1) = samples[i];
|
|
var (a2, s2) = samples[i + 1];
|
|
|
|
if ((s1 <= targetSpan && targetSpan <= s2) ||
|
|
(s2 <= targetSpan && targetSpan <= s1))
|
|
{
|
|
var result = BisectForTarget(patternParts, a1, a2, targetSpan, axis);
|
|
var resultSpan = GetRotatedSpan(patternParts, result, axis);
|
|
var resultDiff = System.Math.Abs(resultSpan - targetSpan);
|
|
|
|
if (resultDiff < bestDiff)
|
|
{
|
|
bestDiff = resultDiff;
|
|
bestAngle = result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestAngle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the rotation angle that orients the pair with its short side
|
|
/// along the given axis. Returns 0 if already oriented, PI/2 if rotated.
|
|
/// </summary>
|
|
private static double OrientShortSideAlong(List<Part> patternParts, NestDirection axis)
|
|
{
|
|
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
|
|
var span0 = GetDimension(box, axis);
|
|
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
|
|
|
|
if (span0 <= perpSpan0)
|
|
return 0;
|
|
|
|
return Angle.HalfPI;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Iteratively finds the rotation angle where N copies of the pattern
|
|
/// span the given dimension with minimal waste by expanding pair width.
|
|
/// Returns (angle, waste, pairCount).
|
|
/// </summary>
|
|
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
|
|
List<Part> patternParts, double sheetSpan, double spacing,
|
|
NestDirection axis, CancellationToken token = default)
|
|
{
|
|
var startAngle = OrientShortSideAlong(patternParts, axis);
|
|
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries fitting N+1 narrower pairs by shrinking the pair width.
|
|
/// Complements ConvergeStripeAngle which only expands.
|
|
/// </summary>
|
|
public static (double Angle, double Waste, int Count) ConvergeStripeAngleShrink(
|
|
List<Part> patternParts, double sheetSpan, double spacing,
|
|
NestDirection axis, CancellationToken token = default)
|
|
{
|
|
var baseAngle = OrientShortSideAlong(patternParts, axis);
|
|
var naturalPattern = FillHelpers.BuildRotatedPattern(patternParts, baseAngle);
|
|
var naturalSpan = GetDimension(naturalPattern.BoundingBox, axis);
|
|
|
|
if (naturalSpan + spacing <= 0)
|
|
return (0, double.MaxValue, 0);
|
|
|
|
var naturalN = (int)System.Math.Floor((sheetSpan + spacing) / (naturalSpan + spacing));
|
|
var targetN = naturalN + 1;
|
|
var targetSpan = (sheetSpan + spacing) / targetN - spacing;
|
|
|
|
if (targetSpan <= 0)
|
|
return (0, double.MaxValue, 0);
|
|
|
|
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
|
|
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
|
}
|
|
|
|
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
|
|
List<Part> patternParts, double startAngle, double sheetSpan,
|
|
double spacing, NestDirection axis, CancellationToken token)
|
|
{
|
|
var bestWaste = double.MaxValue;
|
|
var bestAngle = startAngle;
|
|
var bestCount = 0;
|
|
var tolerance = sheetSpan * 0.001;
|
|
var currentAngle = startAngle;
|
|
|
|
for (var iteration = 0; iteration < MaxConvergenceIterations; iteration++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
|
|
var pairSpan = GetDimension(rotated.BoundingBox, axis);
|
|
var perpDim = axis == NestDirection.Horizontal
|
|
? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
|
|
|
|
if (pairSpan + spacing <= 0)
|
|
break;
|
|
|
|
var stripeBox = axis == NestDirection.Horizontal
|
|
? new Box(0, 0, sheetSpan, perpDim)
|
|
: new Box(0, 0, perpDim, sheetSpan);
|
|
var engine = new FillLinear(stripeBox, spacing);
|
|
var filled = engine.Fill(rotated, axis);
|
|
var n = filled?.Count ?? 0;
|
|
|
|
if (n <= 0)
|
|
break;
|
|
|
|
var filledBox = ((IEnumerable<IBoundable>)filled).GetBoundingBox();
|
|
var remaining = sheetSpan - GetDimension(filledBox, axis);
|
|
|
|
Debug.WriteLine($"[Converge] iter={iteration}: angle={Angle.ToDegrees(currentAngle):F2}°, " +
|
|
$"pairSpan={pairSpan:F4}, perpDim={perpDim:F4}, N={n}, waste={remaining:F3}");
|
|
|
|
if (remaining < bestWaste)
|
|
{
|
|
bestWaste = remaining;
|
|
bestAngle = currentAngle;
|
|
bestCount = n;
|
|
}
|
|
|
|
if (remaining <= tolerance)
|
|
break;
|
|
|
|
var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
|
|
if (bboxN <= 0) bboxN = 1;
|
|
var delta = remaining / bboxN;
|
|
var targetSpan = pairSpan + delta;
|
|
|
|
var prevAngle = currentAngle;
|
|
currentAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
|
|
|
|
if (System.Math.Abs(currentAngle - prevAngle) < Tolerance.Epsilon)
|
|
break;
|
|
}
|
|
|
|
return (bestAngle, bestWaste, bestCount);
|
|
}
|
|
|
|
private static double BisectForTarget(
|
|
List<Part> patternParts, double lo, double hi,
|
|
double targetSpan, NestDirection axis)
|
|
{
|
|
var bestAngle = lo;
|
|
var bestDiff = double.MaxValue;
|
|
|
|
for (var i = 0; i < 30; i++)
|
|
{
|
|
var mid = (lo + hi) / 2;
|
|
var span = GetRotatedSpan(patternParts, mid, axis);
|
|
var diff = System.Math.Abs(span - targetSpan);
|
|
|
|
if (diff < bestDiff)
|
|
{
|
|
bestDiff = diff;
|
|
bestAngle = mid;
|
|
}
|
|
|
|
if (diff < Tolerance.Epsilon)
|
|
break;
|
|
|
|
var loSpan = GetRotatedSpan(patternParts, lo, axis);
|
|
if ((loSpan < targetSpan && span < targetSpan) ||
|
|
(loSpan > targetSpan && span > targetSpan))
|
|
lo = mid;
|
|
else
|
|
hi = mid;
|
|
}
|
|
|
|
return bestAngle;
|
|
}
|
|
|
|
private static double GetRotatedSpan(
|
|
List<Part> patternParts, double angle, NestDirection axis)
|
|
{
|
|
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
|
|
return axis == NestDirection.Horizontal
|
|
? rotated.BoundingBox.Width
|
|
: rotated.BoundingBox.Length;
|
|
}
|
|
|
|
private static double GetDimension(Box box, NestDirection axis)
|
|
{
|
|
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if any pair of parts geometrically overlap. Uses bounding box
|
|
/// pre-filtering for performance, then falls back to shape intersection.
|
|
/// </summary>
|
|
private static bool HasOverlappingParts(List<Part> parts)
|
|
{
|
|
for (var i = 0; i < parts.Count; i++)
|
|
{
|
|
var b1 = parts[i].BoundingBox;
|
|
|
|
for (var j = i + 1; j < parts.Count; j++)
|
|
{
|
|
var b2 = parts[j].BoundingBox;
|
|
|
|
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
|
- System.Math.Max(b1.Left, b2.Left);
|
|
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
|
- System.Math.Max(b1.Bottom, b2.Bottom);
|
|
|
|
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
|
continue;
|
|
|
|
if (parts[i].Intersects(parts[j], out _))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|