diff --git a/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs b/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs
new file mode 100644
index 00000000..86093a39
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs
@@ -0,0 +1,51 @@
+using OpenNest.CNC;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Placement;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+///
+/// 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.
+///
+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] = ("Gusset", 20) }));
+ }
+}
diff --git a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
index 2f32dcbf..e9837f98 100644
--- a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
@@ -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 ConcurrentBag 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(
diff --git a/OpenNest.Engine/Fill/PairCandidateSelector.cs b/OpenNest.Engine/Fill/PairCandidateSelector.cs
index 5031dabe..1d42abf1 100644
--- a/OpenNest.Engine/Fill/PairCandidateSelector.cs
+++ b/OpenNest.Engine/Fill/PairCandidateSelector.cs
@@ -77,6 +77,44 @@ namespace OpenNest.Engine.Fill
);
}
+ ///
+ /// An optimistic count for the candidate's grid plus its leftover strips: each strip at
+ /// least wide (the narrowest the part fits turned) is packed
+ /// at of parts of .
+ ///
+ 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);
diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs
index c50323a9..2dbfdf9b 100644
--- a/OpenNest.Engine/Fill/PairFiller.cs
+++ b/OpenNest.Engine/Fill/PairFiller.cs
@@ -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 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, string> reportProgress
+ Action, 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
///
/// The candidate
/// The
- private static List BuildTilingAngles(BestFitResult candidate)
+ internal static List BuildTilingAngles(BestFitResult candidate)
{
var angles = new List(candidate.HullAngles);
var optAngle = -candidate.OptimalRotation;
diff --git a/OpenNest.Engine/Fill/PairRemnantFiller.cs b/OpenNest.Engine/Fill/PairRemnantFiller.cs
index b5c5bf30..fbcd9c06 100644
--- a/OpenNest.Engine/Fill/PairRemnantFiller.cs
+++ b/OpenNest.Engine/Fill/PairRemnantFiller.cs
@@ -29,6 +29,15 @@ namespace OpenNest.Engine.Fill
private readonly ConcurrentDictionary<(double Width, double Length), List> pairStrips =
new();
+ /// Pairs tried when tiling a strip with one pair as a grid.
+ private const int MaxGridCandidates = 50;
+
+ /// Grid candidates tried in a row without improvement before giving up.
+ private const int GridStaleLimit = 10;
+
+ private readonly ConcurrentDictionary 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 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;
}
+
+ ///
+ /// The strip tiled with one pair as a grid, as 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.
+ ///
+ private List TilePairs(
+ Drawing drawing,
+ Box area,
+ List 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 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);
}
}
diff --git a/OpenNest.Engine/Fill/RotationAnalysis.cs b/OpenNest.Engine/Fill/RotationAnalysis.cs
index 4cb24311..cab26610 100644
--- a/OpenNest.Engine/Fill/RotationAnalysis.cs
+++ b/OpenNest.Engine/Fill/RotationAnalysis.cs
@@ -85,6 +85,46 @@ namespace OpenNest.Engine.Fill
return GetHullEdgeAngles(hull);
}
+ ///
+ /// 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.
+ ///
+ 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())
+ .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 GetHullEdgeAngles(Polygon hull)
{
var vertices = hull.Vertices;
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index fde5fad9..24536d48 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -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