Files
OpenNest/OpenNest.Engine/Fill/StripeFiller.cs
T
ajandClaude Opus 5.5 bcbcf1e611 perf(fill): cut a cold Lattice fill from ~300 s to ~160 s with identical layouts
The first fill of a new drawing was blamed on the best-fit pair search, but on the
reported nest that search takes ~3.4 s. The time goes to FillLinear.FillGrid, run
thousands of times by the Pairs, Row/Column and Linear strategies: pattern copy-distance
slides and the grid overlap check. Every change keeps the output bit for bit (part
poses compared on the benchmark nest; best-fit results unchanged).

- ConvexDecomposition.Triangulate: unlink clipped vertices from a circular list instead
  of List.RemoveAt, and remember why a vertex failed the ear test (same neighbors, same
  blocking vertex still present) instead of retesting it after every ear. Compared
  bitwise against a frozen pre-change copy, including the blocker-clipped case.
- Collision: skip triangles whose box misses the other triangulation's union box (exact:
  min/max and monotone subtraction), reuse each triangle's open vertices, and let the
  overlap-only path stop at its first region when there are no holes. Check still
  returns every region.
- SpatialQuery.ExtractEntityVertices presizes its set; FillLinear extracts each entity
  list's vertices once per copy-distance search rather than once per pair.
- StripeFiller converges its stripe angles and tiles its grids in parallel. Dedup claims,
  cached remnant fills, ranking and progress stay in candidate order, and grids are tiled
  only for configurations a claim can still win (GridDedup.Contains), so results and
  reports do not depend on the thread count.
- LinearFillStrategy fills its angles in parallel through
  FillHelpers.BestOverAnglesInParallel; angle results, ranking and progress stay in order.

Benchmark (one drawing, 60x120, 0.25 spacing, cleared plate, interleaved runs):
FillItem Lattice 291/317 s -> 164/153 s, 132 parts both; PairFiller alone 48 s -> 38-42 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:34:31 -05:00

644 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.Math;
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; }
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 drawing = _context.Item.Drawing;
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
var layouts = ComputeLayouts(bestFits, drawing);
List<Part> bestParts = null;
for (var i = 0; i < bestFits.Count; i++)
{
_context.Token.ThrowIfCancellationRequested();
for (var a = 0; a < Axes.Length; a++)
{
var axis = Axes[a];
var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
for (var v = 0; v < VariantsPerAxis; v++)
{
var layout = layouts[LayoutIndex(i, a, v)];
if (layout.Count <= 0)
continue;
var result = ClaimGrid(layout, axis);
if (result == null || result.Count == 0)
continue;
Debug.WriteLine(
$"[StripeFiller] {strategyName} candidate {i} {dirLabel}: "
+ $"angle={Angle.ToDegrees(layout.Angle):F1}°, N={layout.Count}, waste={layout.Waste:F2}, "
+ $"grid={result.Count} parts"
);
if (_comparer.IsBetter(result, bestParts, workArea))
{
bestParts = result;
}
}
}
_context.ReportProgress(
bestParts,
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts"
);
}
return bestParts ?? new List<Part>();
}
private static readonly NestDirection[] Axes = { NestDirection.Horizontal, NestDirection.Vertical };
/// <summary>Expanding (<see cref="ConvergeStripeAngle"/>) then shrinking convergence.</summary>
private const int VariantsPerAxis = 2;
private static int LayoutIndex(int candidate, int axis, int variant) =>
(candidate * Axes.Length + axis) * VariantsPerAxis + variant;
/// <summary>
/// A converged stripe angle and, when it holds a pattern, the rotated pattern and the grid
/// it tiles before the order-dependent steps (<see cref="GridDedup"/> claim, cached remnant
/// fill).
/// </summary>
private sealed class StripeLayout
{
public double Angle;
public double Waste;
public int Count;
public Pattern Pattern;
public bool GridReady;
public List<Part> Grid;
}
/// <summary>
/// Converges every candidate's stripe angle per axis and variant, then tiles the grids that
/// <see cref="Fill"/> will claim, each step in parallel. Every layout is a pure function of
/// its candidate, axis and variant (fresh part clones, per-call <see cref="FillLinear"/>
/// state), so the results do not depend on the thread count. A grid is tiled only when its
/// configuration is not yet claimed and no earlier layout shares it: claims are only ever
/// added, so exactly those are the grids the sequential loop would have tiled.
/// <see cref="Fill"/> then claims, fills remnants and ranks in the original order.
/// </summary>
private StripeLayout[] ComputeLayouts(List<BestFitResult> bestFits, Drawing drawing)
{
var layouts = new StripeLayout[bestFits.Count * Axes.Length * VariantsPerAxis];
RunParallel(layouts.Length, index =>
{
var variant = index % VariantsPerAxis;
var axis = Axes[index / VariantsPerAxis % Axes.Length];
var candidate = bestFits[index / VariantsPerAxis / Axes.Length];
layouts[index] = Converge(candidate.BuildParts(drawing), axis, variant);
});
var batch = new GridDedup();
var toTile = new List<int>();
for (var index = 0; index < layouts.Length; index++)
{
var layout = layouts[index];
if (layout.Count <= 0)
continue;
var axis = Axes[index / VariantsPerAxis % Axes.Length];
var box = layout.Pattern.BoundingBox;
if (!_dedup.Contains(box, _context.WorkArea, axis) && batch.TryAdd(box, _context.WorkArea, axis))
toTile.Add(index);
}
RunParallel(toTile.Count, k =>
{
var index = toTile[k];
var layout = layouts[index];
layout.Grid = BuildGrid(layout.Pattern, Axes[index / VariantsPerAxis % Axes.Length]);
layout.GridReady = true;
});
return layouts;
}
private void RunParallel(int count, Action<int> body)
{
try
{
Parallel.For(0, count, new ParallelOptions { CancellationToken = _context.Token }, body);
}
catch (AggregateException ex)
{
// Surface the failure itself, cancellation included, as the sequential loop did.
ExceptionDispatchInfo.Capture(ex.InnerExceptions[0]).Throw();
throw;
}
}
private StripeLayout Converge(List<Part> pairParts, NestDirection axis, int variant)
{
var spacing = _context.Plate.PartSpacing;
var sheetSpan = GetDimension(_context.WorkArea, axis);
var (angle, waste, count) = variant == 0
? ConvergeStripeAngle(pairParts, sheetSpan, spacing, axis, _context.Token, _context.MaxQuantity)
: ConvergeStripeAngleShrink(pairParts, sheetSpan, spacing, axis, _context.Token, _context.MaxQuantity);
var layout = new StripeLayout { Angle = angle, Waste = waste, Count = count };
if (count > 0)
layout.Pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
return layout;
}
/// <summary>
/// The order-dependent half of a stripe grid: claims its configuration and adds the cached
/// remnant fill. Null when the configuration was already evaluated or no grid fits.
/// </summary>
private List<Part> ClaimGrid(StripeLayout layout, NestDirection primaryAxis)
{
if (!_dedup.TryAdd(layout.Pattern.BoundingBox, _context.WorkArea, primaryAxis))
return null;
// ComputeLayouts tiled every grid a claim can succeed for; tile here should it not have.
var gridParts = layout.GridReady ? layout.Grid : BuildGrid(layout.Pattern, primaryAxis);
if (gridParts == null)
return null;
var allParts = new List<Part>(gridParts);
// A capped fill that already holds the quantity needs no remnant parts.
if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity)
return allParts;
var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null)
{
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
allParts.AddRange(remnantParts);
}
return allParts;
}
/// <summary>
/// Tiles the rotated pattern into a stripe and the stripe into a grid. Pure: reads only the
/// pattern and the context's fixed inputs.
/// </summary>
private List<Part> BuildGrid(Pattern rotatedPattern, NestDirection primaryAxis)
{
var perpAxis =
primaryAxis == NestDirection.Horizontal ? NestDirection.Vertical : NestDirection.Horizontal;
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
var stripeEngine = new FillLinear(stripeBox, spacing)
{
Label = "Stripe",
MaxParts = _context.MaxQuantity,
};
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)
{
Label = "Stripe-Grid",
MaxParts = _context.MaxQuantity,
};
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;
}
return gridParts;
}
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,
_context.Token
);
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.Length, perpDim)
: new Box(workArea.X, workArea.Y, perpDim, workArea.Width);
}
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.Length, 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.Width);
}
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;
}
var filler = new FillLinear(remnantBox, spacing) { Label = "Stripe-Remnant" };
List<Part> best = null;
foreach (var angle in new[] { 0.0, Angle.HalfPI })
{
_context.Token.ThrowIfCancellationRequested();
var result = FillHelpers.FillWithDirectionPreference(
dir => filler.Fill(drawing, angle, dir),
null,
_comparer,
remnantBox
);
if (result != null && result.Count > (best?.Count ?? 0))
best = result;
}
Debug.WriteLine($"[StripeFiller] Remnant linear: {best?.Count ?? 0} parts");
if (best != null && best.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, spacing, best);
return best;
}
return 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.Width : box.Length;
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). A positive <paramref name="maxParts"/> caps
/// the estimate rows like <see cref="FillLinear.MaxParts"/>.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts,
double sheetSpan,
double spacing,
NestDirection axis,
CancellationToken token = default,
int maxParts = 0
)
{
var startAngle = OrientShortSideAlong(patternParts, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
}
/// <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,
int maxParts = 0
)
{
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, maxParts);
}
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
List<Part> patternParts,
double startAngle,
double sheetSpan,
double spacing,
NestDirection axis,
CancellationToken token,
int maxParts
)
{
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.Width
: rotated.BoundingBox.Length;
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)
{
Label = "Stripe-EstimateRow",
MaxParts = maxParts,
};
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.Length
: rotated.BoundingBox.Width;
}
private static double GetDimension(Box box, NestDirection axis)
{
return axis == NestDirection.Horizontal ? box.Length : box.Width;
}
private static bool HasOverlappingParts(List<Part> parts) =>
FillHelpers.HasOverlappingParts(parts);
}