feat(fill): tile pair grids into narrow leftover strips

Fill tiled 8" gussets on a 16.85 x 60 strip as seven pairs in a 10.35"
column (14) and left a strip too narrow for a gusset square on. Sliding
each pair along its hypotenuse so six span the height narrows the column
to 8.75" and leaves a strip that holds eight more turned 45 degrees (20),
as laid out by hand.

- The pair remnant filler tries strips down to the part's narrowest hull
  width and tiles single best-fit pairs as a grid there, taking the grid
  only when it places more than the rows of pairs.
- After the pair filler's early exit it also tries skipped candidates
  whose grid plus an optimistic leftover-strip estimate could still win,
  keeping one only when it places more parts.
- The optimistic remnant bound counts parts by true area, not bounding
  box, so interlocking parts no longer prune winning candidates.

The mixed Fill nest pin now places 34 instead of 33; the mixed Strip nest
keeps 35 with its L-shapes laid out differently. Both pass the layout check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-08 22:24:49 -05:00
1 parent 5adcddc748
commit f62f73533e
7 files changed
+289 -15

No files matched your search

@@ -0,0 +1,51 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Fill;
/// <summary>
/// A reported nest: 8" gussets (right triangles with clipped corners and a 2.2" hole) on a
/// 16.85 x 60 strip. Fill tiled 7 gusset pairs in a 10.35" column (14) and left a strip too
/// narrow for a gusset square on. Sliding each pair along its hypotenuse so 6 span the height
/// narrows the column to 8.75" and leaves a strip that holds 8 more turned 45 degrees (20), as
/// the user laid it out by hand.
/// </summary>
public class GussetColumnFillTests
{
private static Plate GussetPlate() =>
new(new Size(60, 16.85))
{
PartSpacing = 0.25,
EdgeSpacing = new Spacing(0.5, 0.25, 0.5, 0.25),
};
private static Drawing Gusset()
{
var program = new Program();
program.MoveTo(3.7, 2.6);
program.ArcTo(new Vector(3.7, 2.6), new Vector(2.6, 2.6), RotationType.CCW);
program.MoveTo(0, 0);
foreach (var (x, y) in new[] { (0.0, 8.0), (1.0, 8.0), (8.0, 1.0), (8.0, 0.0), (0.0, 0.0) })
program.LineTo(x, y);
return new Drawing("Gusset", program);
}
[Theory]
[InlineData("Fill")]
[InlineData("Vertical Remnant")]
public void FillSlidesThePairColumnToFitTurnedGussetsBesideIt(string strategy)
{
var plate = GussetPlate();
var drawing = Gusset();
var parts = PlateFillService.FillItem(strategy, plate,
new NestItem { Drawing = drawing, Quantity = 0 }, plate.WorkArea(), null,
CancellationToken.None);
Assert.Equal(20, parts.Count);
Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) },
new Dictionary<Drawing, (string, int)> { [drawing] = ("Gusset", 20) }));
}
}
@@ -33,6 +33,10 @@ namespace OpenNest.Engine.Tests.Jobs;
/// The learned-angle nests were re-captured when the best-fit aspect limit began allowing a
/// part's own aspect: the long bar now nests as an interlocked pair, and the notched quantity
/// dropped from 6 to 4 so the Fill nest still differs from a full sweep.
/// The mixed Fill and Strip nests were re-captured when the pairs phase began tiling its leftover
/// strip with pair grids (and strips narrower than the part's bounding box) and trying skipped
/// candidates whose remnant could win: Fill now places 34 instead of 33, and Strip keeps 35 with
/// its L-shapes laid out differently (both layouts pass NestLayoutCheck).
/// Not pinned: richer mixes that pair parallelogram, chevron or tee parts at oblique angles
/// with other irregular items. Their Fill and Vertical Remnant nests vary between runs, most
/// likely because best-fit pair candidates are gathered in a <c>ConcurrentBag</c> and equal
@@ -276,8 +280,8 @@ public class FillStackCharacterizationTests
}
[Theory]
[InlineData("Fill", 33, "5F41788D261A5085")]
[InlineData("Strip", 35, "865B16D4365A2492")]
[InlineData("Fill", 34, "D3D54E9116D96ACF")]
[InlineData("Strip", 35, "D5A35DDDE6A5B36A")]
[InlineData("Vertical Remnant", 35, "8CC17C7708C8049F")]
[InlineData("Horizontal Remnant", 30, "A0700052AE771E06")]
public void Nest_MixedIrregularItems_PinnedLayout(
@@ -77,6 +77,44 @@ namespace OpenNest.Engine.Fill
);
}
/// <summary>
/// An optimistic count for the candidate's grid plus its leftover strips: each strip at
/// least <paramref name="minWidth"/> wide (the narrowest the part fits turned) is packed
/// at <paramref name="maxUtilization"/> of parts of <paramref name="partArea"/>.
/// </summary>
public int EstimateWithRemnant(
BestFitResult r,
Box workArea,
double minWidth,
double maxUtilization,
double partArea
)
{
int Estimate(double pairW, double pairH)
{
if (pairW <= 0 || pairH <= 0 || partArea <= 0)
return 0;
var areaW = workArea.Width;
var areaL = workArea.Length;
var cols = (int)((areaW + partSpacing) / (pairW + partSpacing));
var rows = (int)((areaL + partSpacing) / (pairH + partSpacing));
if (cols == 0 || rows == 0)
return 0;
var leftW = areaW - cols * (pairW + partSpacing);
var leftL = areaL - rows * (pairH + partSpacing);
var strips = (leftW >= minWidth ? leftW * areaL : 0)
+ (leftL >= minWidth ? leftL * areaW : 0);
return cols * rows * 2 + (int)(strips * maxUtilization / partArea);
}
return System.Math.Max(
Estimate(r.BoundingWidth, r.BoundingHeight),
Estimate(r.BoundingHeight, r.BoundingWidth)
);
}
private int EstimateTileCount(BestFitResult r, double areaW, double areaL)
{
var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL);
+54 -13
View File
@@ -185,18 +185,26 @@ namespace OpenNest.Engine.Fill
var effectiveWorkArea = workArea;
var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0;
// The remnant bound packs parts at the best pair utilization, which is measured against
// the part's true area: its bounding box would undercount parts that interlock, such
// as gussets, and prune candidates whose remnant could win.
var partBox = drawing.Program.BoundingBox();
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
var partArea = drawing.Area > 0
? drawing.Area
: System.Math.Max(partBox.Width * partBox.Length, 1);
try
// Evaluates the batches of a candidate list; with early exit, stops once that many
// candidates in a row failed to improve and returns where it stopped. With requireMore,
// a candidate replaces the best only with more parts, so a tie keeps the layout.
int RunBatches(List<BestFitResult> list, bool earlyExit, bool requireMore)
{
for (var batchStart = 0; batchStart < candidates.Count; batchStart += CandidateBatchSize)
for (var batchStart = 0; batchStart < list.Count; batchStart += CandidateBatchSize)
{
token.ThrowIfCancellationRequested();
var batchEnd = System.Math.Min(batchStart + CandidateBatchSize, candidates.Count);
var batchEnd = System.Math.Min(batchStart + CandidateBatchSize, list.Count);
var results = EvaluateBatch(
candidates,
list,
drawing,
effectiveWorkArea,
batchStart,
@@ -215,17 +223,47 @@ namespace OpenNest.Engine.Fill
workArea,
effectiveWorkArea,
targetCount,
candidates.Count,
list.Count,
batchStart,
reportProgress
reportProgress,
requireMore
);
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
if (earlyExit && batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
{
Debug.WriteLine(
$"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates"
$"[PairFiller] Early exit at {batchEnd}/{list.Count} — no improvement in last {sinceImproved} candidates"
);
break;
return batchEnd;
}
}
return list.Count;
}
try
{
var stoppedAt = RunBatches(candidates, earlyExit: true, requireMore: false);
// The candidates are ranked by their grids alone, so the early exit can skip a
// narrower grid whose leftover strip holds more parts than a fuller grid gains:
// a column of gusset pairs slid to leave a strip for gussets turned 45 degrees.
// Those whose grid and remnant estimate could still win are tried anyway.
if (stoppedAt < candidates.Count && best != null
&& (targetCount <= 0 || best.Count < targetCount))
{
var minWidth = RotationAnalysis.MinimumWidth(drawing);
var promising = candidates
.Skip(stoppedAt)
.Where(c => candidateSelector.EstimateWithRemnant(
c, workArea, minWidth, maxUtilization, partArea) > best.Count)
.ToList();
if (promising.Count > 0)
{
Debug.WriteLine(
$"[PairFiller] Trying {promising.Count} skipped candidates whose remnant could win"
);
RunBatches(promising, earlyExit: false, requireMore: true);
}
}
}
@@ -293,12 +331,15 @@ namespace OpenNest.Engine.Fill
int targetCount,
int totalCandidates,
int batchStart,
Action<List<Part>, string> reportProgress
Action<List<Part>, string> reportProgress,
bool requireMore = false
)
{
for (var j = 0; j < results.Length; j++)
{
if (comparer.IsBetter(results[j], best, effectiveWorkArea))
var placesMore = !requireMore || best == null
|| (results[j]?.Count ?? 0) > best.Count;
if (placesMore && comparer.IsBetter(results[j], best, effectiveWorkArea))
{
best = results[j];
sinceImproved = 0;
@@ -511,7 +552,7 @@ namespace OpenNest.Engine.Fill
/// </summary>
/// <param name="candidate">The candidate<see cref="BestFitResult"/></param>
/// <returns>The <see cref="List{double}"/></returns>
private static List<double> BuildTilingAngles(BestFitResult candidate)
internal static List<double> BuildTilingAngles(BestFitResult candidate)
{
var angles = new List<double>(candidate.HullAngles);
var optAngle = -candidate.OptimalRotation;
+90
View File
@@ -29,6 +29,15 @@ namespace OpenNest.Engine.Fill
private readonly ConcurrentDictionary<(double Width, double Length), List<Part>> pairStrips =
new();
/// <summary>Pairs tried when tiling a strip with one pair as a grid.</summary>
private const int MaxGridCandidates = 50;
/// <summary>Grid candidates tried in a row without improvement before giving up.</summary>
private const int GridStaleLimit = 10;
private readonly ConcurrentDictionary<Drawing, double> minimumWidths =
new(ReferenceEqualityComparer.Instance);
public PairRemnantFiller(double partSpacing, IFillComparer comparer)
{
this.partSpacing = partSpacing;
@@ -73,6 +82,11 @@ namespace OpenNest.Engine.Fill
var partBox = drawing.Program.BoundingBox();
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
// With pairs to tile, a strip narrower than the part's bounding box can still hold
// it turned, as a gusset turned 45 degrees fits beside a column of gusset pairs.
if (bestFits != null && plate != null)
minDim = System.Math.Min(minDim, MinimumWidth(drawing));
List<Part> bestRemnant = null;
// Try top remnant (full width, above grid)
@@ -214,11 +228,87 @@ namespace OpenNest.Engine.Fill
// The stripe filler lays rows along both axes itself; its axis only names it.
var best = new StripeFiller(context, NestDirection.Horizontal).Fill();
// A grid replaces the stripes only when it places more; a tie keeps the stripes.
var grid = TilePairs(drawing, area, bestFits, token);
if (grid != null && grid.Count > (best?.Count ?? 0))
best = grid;
Debug.WriteLine(
$"[PairFiller] Remnant pairs: {best?.Count ?? 0} parts in "
+ $"{area.Width:F2}x{area.Length:F2}"
);
return best;
}
/// <summary>
/// The strip tiled with one pair as a grid, as <see cref="PairFiller"/> tiles a work area,
/// trying the pairs whose short side fits the strip. Finds layouts the stripe filler's
/// turned rows miss, such as gusset pairs at 45 degrees zigzagging up a narrow strip.
/// </summary>
private List<Part> TilePairs(
Drawing drawing,
Box area,
List<BestFitResult> bestFits,
CancellationToken token
)
{
var shortSide = System.Math.Min(area.Width, area.Length);
var candidates = bestFits
.Where(fit => fit.Keep && fit.ShortestSide <= shortSide + Tolerance.Epsilon)
.OrderByDescending(fit => EstimateTileCount(fit, area))
.ThenByDescending(fit => fit.Utilization)
.Take(MaxGridCandidates);
var engine = new FillLinear(area, partSpacing) { Label = "Pairs-Remnant" };
List<Part> best = null;
var sinceImproved = 0;
foreach (var fit in candidates)
{
token.ThrowIfCancellationRequested();
if (sinceImproved >= GridStaleLimit)
break;
var improved = false;
var pairParts = fit.BuildParts(drawing);
foreach (var angle in PairFiller.BuildTilingAngles(fit))
{
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
if (pattern.Parts.Count == 0)
continue;
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var parts = engine.Fill(pattern, dir);
if (parts == null || parts.Count == 0 || !comparer.IsBetter(parts, best, area))
continue;
best = parts;
improved = true;
}
}
sinceImproved = improved ? 0 : sinceImproved + 1;
}
return best;
}
private int EstimateTileCount(BestFitResult fit, Box area)
{
int Count(double pairW, double pairH) =>
pairW <= 0 || pairH <= 0
? 0
: (int)((area.Width + partSpacing) / (pairW + partSpacing))
* (int)((area.Length + partSpacing) / (pairH + partSpacing))
* 2;
return System.Math.Max(
Count(fit.BoundingWidth, fit.BoundingHeight),
Count(fit.BoundingHeight, fit.BoundingWidth)
);
}
private double MinimumWidth(Drawing drawing) =>
minimumWidths.GetOrAdd(drawing, RotationAnalysis.MinimumWidth);
}
}
+40
View File
@@ -85,6 +85,46 @@ namespace OpenNest.Engine.Fill
return GetHullEdgeAngles(hull);
}
/// <summary>
/// The narrowest strip the drawing's material fits in at any rotation: the least width of
/// its convex hull, which is found across one of the hull's edges. A right-angle gusset
/// fits a strip narrower than either side of its bounding box when turned 45 degrees.
/// Zero when the drawing has no material outline.
/// </summary>
public static double MinimumWidth(Drawing drawing)
{
var shapes = ShapeBuilder.GetShapes(
ConvertProgram.ToGeometry(drawing.Program).ForNesting()
);
var points = shapes
.SelectMany(s => s.ToPolygonWithTolerance(0.01)?.Vertices ?? new List<Vector>())
.ToList();
if (points.Count < 3)
return 0;
var vertices = ConvexHull.Compute(points).Vertices;
var minimum = double.MaxValue;
for (var i = 0; i < vertices.Count; i++)
{
var a = vertices[i];
var b = vertices[(i + 1) % vertices.Count];
var length = a.DistanceTo(b);
if (length < Tolerance.Epsilon)
continue;
var width = 0.0;
foreach (var v in vertices)
{
var cross = (b.X - a.X) * (v.Y - a.Y) - (b.Y - a.Y) * (v.X - a.X);
width = System.Math.Max(width, System.Math.Abs(cross) / length);
}
minimum = System.Math.Min(minimum, width);
}
return minimum == double.MaxValue ? 0 : minimum;
}
public static List<double> GetHullEdgeAngles(Polygon hull)
{
var vertices = hull.Vertices;
+10
View File
@@ -115,6 +115,16 @@ included, and keeps whichever places the most; a tie stays staggered. A 9-inch c
60 x 120 sheet at 0.2 spacing fits 89 that way (five staggered rows of 13 and 12, then two
straight rows of 13) against 88 staggered throughout.
Lattice's pairs phase also tiles the strip its grid leaves with one best-fit pair as a grid, and
tries a strip narrower than the part's bounding box when the part fits it turned (the least width
of its convex hull); the grid replaces the rows of pairs only when it places more. Its candidates
are ranked by grid count alone and it stops after ten in a row fail to improve, so it then also
tries the skipped candidates whose grid plus an optimistic estimate of their leftover strips could
still win, keeping one only when it places more parts; the estimate counts parts by their true
area, not their bounding box. An 8" gusset on a 16.85 x 60 strip fills 20 that way: six pairs slid
along their hypotenuse in an 8.75" column, and eight gussets at 45 degrees zigzagging up the strip
beside it, against 14 in a 10.35" column of seven pairs.
Lattice's last strategy, TiltedRemnant, adds tilted parts beside the best layout when the room
left past it on the right or top is too narrow for the part at 0 or 90 degrees, such as a long
part beside a grid of the same part turned 90 degrees. It moves the layout flush to the low end