diff --git a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
index 40d63fda..8acbf587 100644
--- a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs
@@ -27,6 +27,9 @@ namespace OpenNest.Engine.Tests.Jobs;
/// processor count); fix that rather than repinning. The whole-plate Fill nests fill several
/// irregular drawings with one filler, so later items use the angles
/// AngleCandidateBuilder.RecordProductive learned from earlier ones.
+/// Re-captured when quantity trimming began keeping interlocked pairs whole
+/// (ShrinkFiller.TrimToCount): nine quantity-limited cases kept their counts but drop
+/// different copies. Whole-job leftovers trying pairs first changed none of these pins.
/// 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
@@ -211,16 +214,22 @@ public class FillStackCharacterizationTests
{
var plate = MakePlate();
var item = new NestItem { Drawing = new Drawing(shape, Shape(shape)), Quantity = 0 };
- return Layout(
- PlateFillService.FillItem(
- strategy,
- plate,
- item,
- plate.WorkArea(),
- null,
- CancellationToken.None
- )
+ var parts = PlateFillService.FillItem(
+ strategy,
+ plate,
+ item,
+ plate.WorkArea(),
+ null,
+ CancellationToken.None
);
+ Assert.Empty(NestLayoutCheck.Validate(
+ new List<(Plate, List)> { (plate, parts) },
+ new Dictionary
+ {
+ [item.Drawing] = (item.Drawing.Name, parts.Count),
+ }
+ ));
+ return Layout(parts);
});
AssertPinned(layout, expectedCount, expectedDigest);
@@ -281,7 +290,7 @@ public class FillStackCharacterizationTests
[Theory]
[InlineData("Fill", 30, "5732030365D13B9A")]
- [InlineData("Strip", 30, "A573BA6919FCD300")]
+ [InlineData("Strip", 30, "666F8E276808A9C2")]
[InlineData("Vertical Remnant", 30, "5732030365D13B9A")]
[InlineData("Horizontal Remnant", 30, "C05F6A30A648B929")]
public void Nest_LearnedAngleItems_PinnedLayout(
@@ -315,10 +324,10 @@ public class FillStackCharacterizationTests
// --- interactive fill beside clamps ---
[Theory]
- [InlineData("Fill", 40, "9B3EFF9B72243576")]
- [InlineData("Strip", 40, "9B3EFF9B72243576")]
- [InlineData("Vertical Remnant", 40, "039C0D607166D805")]
- [InlineData("Horizontal Remnant", 40, "C1E81564A620302A")]
+ [InlineData("Fill", 40, "5D07DCF3DC9B5F57")]
+ [InlineData("Strip", 40, "5D07DCF3DC9B5F57")]
+ [InlineData("Vertical Remnant", 40, "7072140D787044BD")]
+ [InlineData("Horizontal Remnant", 40, "8FDDB0EA5D6D3620")]
public void FillItem_WithClampZones_PinnedLayout(
string strategy,
int expectedCount,
@@ -334,16 +343,22 @@ public class FillStackCharacterizationTests
Drawing = new Drawing("lshape", TestDrawingFactory.LShape()),
Quantity = 40,
};
- return Layout(
- PlateFillService.FillItem(
- strategy,
- plate,
- item,
- plate.WorkArea(),
- null,
- CancellationToken.None
- )
+ var parts = PlateFillService.FillItem(
+ strategy,
+ plate,
+ item,
+ plate.WorkArea(),
+ null,
+ CancellationToken.None
);
+ Assert.Empty(NestLayoutCheck.Validate(
+ new List<(Plate, List)> { (plate, parts) },
+ new Dictionary
+ {
+ [item.Drawing] = (item.Drawing.Name, parts.Count),
+ }
+ ));
+ return Layout(parts);
});
AssertPinned(layout, expectedCount, expectedDigest);
@@ -368,10 +383,10 @@ public class FillStackCharacterizationTests
);
[Theory]
- [InlineData("Fill", 26, "64CE33B01B205978")]
- [InlineData("Strip", 26, "FD03A4529D37E32C")]
- [InlineData("Vertical Remnant", 26, "BDCA1DA111B396F0")]
- [InlineData("Horizontal Remnant", 26, "C356EC216CD56627")]
+ [InlineData("Fill", 26, "0A7A67E19F821E84")]
+ [InlineData("Strip", 26, "06ABFA7EBBF15472")]
+ [InlineData("Vertical Remnant", 26, "0A7A67E19F821E84")]
+ [InlineData("Horizontal Remnant", 26, "25DC495707C2BCA4")]
public void FixedStrategyEngine_IrregularJob_PinnedLayout(
string strategy,
int expectedCount,
diff --git a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs
index 35c09ce5..21e2a41d 100644
--- a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs
@@ -170,17 +170,17 @@ public class GoldenLayoutTests
MixedJob("Vertical Remnant")
);
Assert.Equal(NestJobStatus.Complete, result.Status);
- // Coincides with Default on this job; pinned independently so strategy divergence
- // after extraction is caught even where layouts agree today.
+ // Pairs-first leftovers beat pack-first here (26 long, not 28); pinned independently
+ // so strategy divergence after extraction is caught.
AssertGolden(
result,
[
- P("arc", 0, 20, 1, 1.5707963267948966),
- P("arc", 1, 20, 7.5, 1.5707963267948966),
- P("arc", 2, 20, 14, 1.5707963267948966),
- P("lshape", 0, 19, 20.5, 1.5707963267948966),
- P("lshape", 1, 24, 20.5, 1.5707963267948966),
- P("lshape", 2, 25, 1, 1.5707963267948966),
+ P("arc", 0, 21, 1, 1.5707963267948966),
+ P("arc", 1, 21, 7.5, 1.5707963267948966),
+ P("arc", 2, 21, 14, 1.5707963267948966),
+ P("lshape", 0, 20, 20.5, 1.5707963267948966),
+ P("lshape", 1, 25, 20.5, 1.5707963267948966),
+ P("lshape", 2, 26, 1, 1.5707963267948966),
P("rect-a", 0, 5, 21, 1.5707963267948966),
P("rect-a", 1, 1, 1, 0),
P("rect-a", 2, 1, 6, 0),
@@ -189,12 +189,12 @@ public class GoldenLayoutTests
P("rect-a", 5, 10, 21, 1.5707963267948966),
P("rect-a", 6, 8, 1, 0),
P("rect-a", 7, 8, 6, 0),
- P("rect-b", 0, 24, 8, 1.5707963267948966),
- P("rect-b", 1, 24, 13, 1.5707963267948966),
- P("rect-b", 2, 28, 8, 1.5707963267948966),
- P("rect-b", 3, 28, 13, 1.5707963267948966),
- P("rect-b", 4, 28, 18, 1.5707963267948966),
- P("rect-b", 5, 28, 23, 1.5707963267948966),
+ P("rect-b", 0, 11, 11, 1.5707963267948966),
+ P("rect-b", 1, 11, 16, 1.5707963267948966),
+ P("rect-b", 2, 15, 11, 1.5707963267948966),
+ P("rect-b", 3, 15, 16, 1.5707963267948966),
+ P("rect-b", 4, 11, 21, 0),
+ P("rect-b", 5, 11, 25, 0),
]
);
}
diff --git a/OpenNest.Engine/Fill/InterlockedPairs.cs b/OpenNest.Engine/Fill/InterlockedPairs.cs
new file mode 100644
index 00000000..186000ce
--- /dev/null
+++ b/OpenNest.Engine/Fill/InterlockedPairs.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Fill
+{
+ ///
+ /// Finds the interlocked pairs in a fill pattern. Separate parts keep spacing apart, so only
+ /// an interlocked pair's bounding boxes overlap; each part pairs with the one it overlaps most.
+ ///
+ internal static class InterlockedPairs
+ {
+ /// Each part's partner index, or -1 for a part outside any pair.
+ internal static int[] Partners(IReadOnlyList boxes)
+ {
+ var partner = Enumerable.Repeat(-1, boxes.Count).ToArray();
+ var overlaps = new List<(double Area, int A, int B)>();
+ // Sweep by left edge: once a box starts past this one's right edge, so do the rest.
+ var byLeft = Enumerable.Range(0, boxes.Count).OrderBy(i => boxes[i].Left).ThenBy(i => i).ToArray();
+ for (var m = 0; m < byLeft.Length; m++)
+ for (var n = m + 1; n < byLeft.Length && boxes[byLeft[n]].Left < boxes[byLeft[m]].Right; n++)
+ {
+ var i = System.Math.Min(byLeft[m], byLeft[n]);
+ var j = System.Math.Max(byLeft[m], byLeft[n]);
+ var a = boxes[i];
+ var b = boxes[j];
+ var length = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left);
+ var width = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom);
+ if (length > Tolerance.Epsilon && width > Tolerance.Epsilon)
+ overlaps.Add((length * width, i, j));
+ }
+ foreach (var (_, a, b) in overlaps.OrderByDescending(o => o.Area).ThenBy(o => o.A).ThenBy(o => o.B))
+ if (partner[a] < 0 && partner[b] < 0)
+ {
+ partner[a] = b;
+ partner[b] = a;
+ }
+ return partner;
+ }
+ }
+}
diff --git a/OpenNest.Engine/Fill/ShrinkFiller.cs b/OpenNest.Engine/Fill/ShrinkFiller.cs
index 0e1d0187..b060d137 100644
--- a/OpenNest.Engine/Fill/ShrinkFiller.cs
+++ b/OpenNest.Engine/Fill/ShrinkFiller.cs
@@ -2,9 +2,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
-using OpenNest.Geometry;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Engine.RectanglePacking;
+using OpenNest.Geometry;
namespace OpenNest.Engine.Fill
{
@@ -161,6 +161,8 @@ namespace OpenNest.Engine.Fill
///
/// Keeps the parts nearest to the origin
/// along the given axis, discarding parts farthest from the origin.
+ /// Interlocked pairs are kept or discarded whole; a pair is split only when an
+ /// odd count leaves no unpaired part to keep instead.
/// Returns the input list unchanged if count is already at or below target.
///
internal static List TrimToCount(List parts, int targetCount, ShrinkAxis axis)
@@ -168,9 +170,34 @@ namespace OpenNest.Engine.Fill
if (parts == null || parts.Count <= targetCount)
return parts;
- return axis == ShrinkAxis.Width
- ? parts.OrderBy(p => p.BoundingBox.Right).Take(targetCount).ToList()
- : parts.OrderBy(p => p.BoundingBox.Top).Take(targetCount).ToList();
+ var boxes = parts.Select(p => p.BoundingBox).ToArray();
+ (double, double) Key(int i) => axis == ShrinkAxis.Width
+ ? (boxes[i].Right, boxes[i].Top)
+ : (boxes[i].Top, boxes[i].Right);
+ var partner = InterlockedPairs.Partners(boxes);
+ // A pair's later member decides when it is discarded; within a unit, the nearer first.
+ var units = Enumerable.Range(0, parts.Count)
+ .Where(i => partner[i] < 0 || i < partner[i])
+ .Select(i => (partner[i] < 0 ? new[] { i } : new[] { i, partner[i] })
+ .OrderBy(Key).ThenBy(j => j).ToArray())
+ .OrderBy(u => Key(u[^1])).ThenBy(u => u[0])
+ .ToList();
+
+ var kept = new List(targetCount);
+ int[] split = null;
+ foreach (var unit in units)
+ {
+ if (kept.Count == targetCount)
+ break;
+ if (kept.Count + unit.Length <= targetCount)
+ kept.AddRange(unit);
+ else
+ split ??= unit;
+ }
+ if (kept.Count < targetCount)
+ kept.Add(split[0]);
+
+ return kept.OrderBy(Key).ThenBy(i => i).Select(i => parts[i]).ToList();
}
}
}
diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
index 35526f9d..2fdd4467 100644
--- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
+++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs
@@ -73,44 +73,38 @@ internal static class PlateFillOrchestrator
DeductPlacedQuantities(fillItems, fillParts);
- var placedObstacles = fillParts
- .Select(part => part.BoundingBox.Offset(plate.PartSpacing))
- .ToList();
- placedObstacles.AddRange(clampKeepOuts);
- var finder = new RemnantFinder(workArea, placedObstacles);
- var remnants = finder.FindRemnants();
- if (remnants.Count > 0)
- workArea = remnants[0];
- else
- workArea = new Box(0, 0, 0, 0);
+ workArea = LargestRemnant(plate, workArea, fillParts, clampKeepOuts);
}
}
- packItems = packItems.Where(item => item.Quantity > 0).ToList();
- var pairItems = packItems.Where(item => item.Quantity == 2).ToList();
- var regularPackItems = packItems.Where(item => item.Quantity != 2).ToList();
+ var leftovers = packItems.Where(item => item.Quantity > 0).ToList();
- if (
- regularPackItems.Count > 0
- && workArea.Width > 0
- && workArea.Length > 0
- && !token.IsCancellationRequested
- )
+ if (leftovers.Count > 0 && !token.IsCancellationRequested)
{
- var packParts = pack(workArea, regularPackItems, progress, token);
+ var packFirst = PackThenPairs(plate, leftovers, allParts, workArea, comparer, pack,
+ clampKeepOuts, progress, token);
+ var chosen = packFirst;
- if (packParts.Count > 0)
+ // Neither order wins every job: pairs reach the gaps a fill leaves before singles
+ // take them, but plain rectangles often pack tighter one at a time. The strategy's
+ // own ranking of the whole layout decides, and a tie keeps pack-first.
+ if (
+ leftovers.Any(IsPairable)
+ && !token.IsCancellationRequested
+ )
{
- allParts.AddRange(packParts);
-
- DeductPlacedQuantities(regularPackItems, packParts);
+ var pairsFirst = PairsThenPack(plate, leftovers, allParts, workArea, comparer, pack,
+ clampKeepOuts, token);
+ if (comparer.IsBetter(
+ allParts.Concat(pairsFirst).ToList(),
+ allParts.Concat(packFirst).ToList(),
+ plate.WorkArea()
+ ))
+ chosen = pairsFirst;
}
- }
- if (pairItems.Count > 0 && !token.IsCancellationRequested)
- {
- var placed = PlaceBestFitPairs(plate, comparer, pairItems, allParts, plate.WorkArea(), clampKeepOuts);
- allParts.AddRange(placed);
+ allParts.AddRange(chosen);
+ DeductPlacedQuantities(leftovers, chosen);
}
Compactor.Settle(allParts, plate);
@@ -118,6 +112,97 @@ internal static class PlateFillOrchestrator
return allParts;
}
+ private static bool IsPairable(NestItem item) => item.Quantity >= 2 && item.Quantity % 2 == 0;
+
+ private static List Copies(IEnumerable items) =>
+ items.Select(item => new NestItem
+ {
+ Drawing = item.Drawing,
+ Priority = item.Priority,
+ Quantity = item.Quantity,
+ StepAngle = item.StepAngle,
+ RotationStart = item.RotationStart,
+ RotationEnd = item.RotationEnd,
+ }).ToList();
+
+ /// Packs everything but quantity-two items, then places those as pairs.
+ private static List PackThenPairs(
+ Plate plate,
+ List leftovers,
+ List existingParts,
+ Box workArea,
+ IFillComparer comparer,
+ Func, IProgress, CancellationToken, List> pack,
+ IReadOnlyList clampKeepOuts,
+ IProgress progress,
+ CancellationToken token
+ )
+ {
+ var items = Copies(leftovers);
+ var placed = new List();
+ var packItems = items.Where(item => item.Quantity != 2).ToList();
+
+ if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0)
+ placed.AddRange(pack(workArea, packItems, progress, token));
+
+ var pairItems = items.Where(item => item.Quantity == 2).ToList();
+ if (pairItems.Count > 0 && !token.IsCancellationRequested)
+ placed.AddRange(PlaceBestFitPairs(plate, comparer, pairItems,
+ existingParts.Concat(placed).ToList(), plate.WorkArea(), clampKeepOuts));
+
+ return placed;
+ }
+
+ ///
+ /// Places every even quantity as pairs, largest box first so a part that fits a gap reaches
+ /// it before a smaller one takes it, then packs the rest around them.
+ ///
+ private static List PairsThenPack(
+ Plate plate,
+ List leftovers,
+ List existingParts,
+ Box workArea,
+ IFillComparer comparer,
+ Func, IProgress, CancellationToken, List> pack,
+ IReadOnlyList clampKeepOuts,
+ CancellationToken token
+ )
+ {
+ var items = Copies(leftovers);
+ var pairItems = items
+ .Where(IsPairable)
+ .OrderByDescending(item => item.Drawing.Program.BoundingBox().Area())
+ .ToList();
+
+ var placed = PlaceBestFitPairs(plate, comparer, pairItems, existingParts, plate.WorkArea(), clampKeepOuts);
+ if (placed.Count > 0)
+ workArea = LargestRemnant(plate, plate.WorkArea(), existingParts.Concat(placed), clampKeepOuts);
+
+ var packItems = items.Where(item => item.Quantity > 0).ToList();
+ if (
+ packItems.Count > 0
+ && workArea.Width > 0
+ && workArea.Length > 0
+ && !token.IsCancellationRequested
+ )
+ placed.AddRange(pack(workArea, packItems, null, token));
+
+ return placed;
+ }
+
+ private static Box LargestRemnant(
+ Plate plate,
+ Box area,
+ IEnumerable parts,
+ IReadOnlyList clampKeepOuts
+ )
+ {
+ var obstacles = parts.Select(part => part.BoundingBox.Offset(plate.PartSpacing)).ToList();
+ obstacles.AddRange(clampKeepOuts);
+ var remnants = new RemnantFinder(area, obstacles).FindRemnants();
+ return remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
+ }
+
private static List BetterWithClampHitsDropped(
Plate plate,
NestItem item,
@@ -167,12 +252,10 @@ internal static class PlateFillOrchestrator
if (clampKeepOuts != null)
obstacles.AddRange(clampKeepOuts);
var finder = new RemnantFinder(fullWorkArea, obstacles);
+ var layout = new List(existingParts);
foreach (var item in pairItems)
{
- if (item.Quantity < 2)
- continue;
-
var bestFits = BestFitCache.GetOrCompute(
item.Drawing,
plate.Size.Length,
@@ -182,67 +265,114 @@ internal static class PlateFillOrchestrator
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
- List bestPlacement = null;
- Box bestTarget = null;
+ // A part free to rotate may lie along either sheet axis as a pair.
+ var turns = item.StepAngle == 0 && item.RotationStart == 0 && item.RotationEnd == 0
+ ? new[] { 0, Angle.HalfPI }
+ : new[] { 0.0 };
- foreach (var fit in bestFits)
+ while (item.Quantity >= 2)
{
- if (!fit.Keep)
- continue;
+ List bestPlacement = null;
+ Box bestTarget = null;
- var parts = fit.BuildParts(canonicalDrawing);
- var pairBbox = ((IEnumerable)parts).GetBoundingBox();
- var pairWidth = pairBbox.Width;
- var pairLength = pairBbox.Length;
- var minDimension = System.Math.Min(pairWidth, pairLength);
-
- var remnants = finder.FindRemnants(minDimension);
-
- foreach (var remnant in remnants)
+ foreach (var fit in bestFits)
{
- if (
- pairWidth <= remnant.Width + Tolerance.Epsilon
- && pairLength <= remnant.Length + Tolerance.Epsilon
- )
- {
- var offset = remnant.Location - pairBbox.Location;
- foreach (var part in parts)
- {
- part.Offset(offset);
- part.UpdateBounds();
- }
+ if (!fit.Keep)
+ continue;
- if (bestPlacement == null || comparer.IsBetter(parts, bestPlacement, remnant))
+ foreach (var turn in turns)
+ {
+ var probe = ((IEnumerable)BuildPair(fit, canonicalDrawing, turn)).GetBoundingBox();
+ var pairWidth = probe.Width;
+ var pairLength = probe.Length;
+
+ foreach (var remnant in finder.FindRemnants(System.Math.Min(pairWidth, pairLength)))
{
- bestPlacement = parts;
- bestTarget = remnant;
+ if (
+ pairWidth > remnant.Width + Tolerance.Epsilon
+ || pairLength > remnant.Length + Tolerance.Epsilon
+ )
+ continue;
+
+ var parts = BuildPair(fit, canonicalDrawing, turn);
+ var offset = remnant.Location - probe.Location;
+ foreach (var part in parts)
+ {
+ part.Offset(offset);
+ part.UpdateBounds();
+ }
+
+ // Ranked with the layout around it: alone, a pair's own extent says
+ // nothing about whether it stays inside the layout or lengthens it.
+ if (
+ bestPlacement == null
+ || comparer.IsBetter(layout.Concat(parts).ToList(), layout.Concat(bestPlacement).ToList(), fullWorkArea)
+ )
+ {
+ bestPlacement = parts;
+ bestTarget = remnant;
+ }
}
- break;
}
}
+
+ if (bestPlacement == null)
+ break;
+
+ bestPlacement = CanonicalFrame.RebindToOriginal(bestPlacement, item.Drawing);
+ CompactPair(bestPlacement, layout, plate, fullWorkArea);
+
+ result.AddRange(bestPlacement);
+ layout.AddRange(bestPlacement);
+ item.Quantity -= 2;
+
+ var envelope = ((IEnumerable)bestPlacement).GetBoundingBox();
+ finder.AddObstacle(envelope.Offset(plate.PartSpacing));
+
+ Debug.WriteLine(
+ $"[Nest] Placed best-fit pair for {item.Drawing.Name} "
+ + $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), "
+ + $"size {envelope.Width:F1}x{envelope.Length:F1}"
+ );
}
-
- if (bestPlacement == null)
- continue;
-
- bestPlacement = CanonicalFrame.RebindToOriginal(bestPlacement, item.Drawing);
-
- result.AddRange(bestPlacement);
- item.Quantity = 0;
-
- var envelope = ((IEnumerable)bestPlacement).GetBoundingBox();
- finder.AddObstacle(envelope.Offset(plate.PartSpacing));
-
- Debug.WriteLine(
- $"[Nest] Placed best-fit pair for {item.Drawing.Name} "
- + $"at ({bestTarget.X:F1},{bestTarget.Y:F1}), "
- + $"size {envelope.Width:F1}x{envelope.Length:F1}"
- );
}
return result;
}
+ ///
+ /// Slides a pair placed by its bounding box left then down against the true outlines around
+ /// it, so it takes the space its box could not. The whole-layout settle cannot: it moves
+ /// everything as one group. Clamp keep-outs stay walls, here at the part spacing.
+ ///
+ private static void CompactPair(List pair, List layout, Plate plate, Box workArea)
+ {
+ const double threshold = 0.01;
+ const int maxIterations = 20;
+
+ var obstacles = layout.Concat(plate.Parts).Concat(InteractiveClamps.ObstacleParts(plate)).ToList();
+
+ for (var i = 0; i < maxIterations; i++)
+ {
+ var moved = Compactor.Push(pair, obstacles, workArea, plate.PartSpacing, PushDirection.Left)
+ + Compactor.Push(pair, obstacles, workArea, plate.PartSpacing, PushDirection.Down);
+ if (moved < threshold)
+ break;
+ }
+ }
+
+ private static List BuildPair(BestFitResult fit, Drawing drawing, double turn)
+ {
+ var parts = fit.BuildParts(drawing);
+ if (turn != 0)
+ foreach (var part in parts)
+ {
+ part.Rotate(turn, new Vector());
+ part.UpdateBounds();
+ }
+ return parts;
+ }
+
private static bool ShouldFill(NestItem item, Plate plate, double plateArea)
{
if (item.Quantity <= 1)
diff --git a/OpenNest.Tests/Fill/ShrinkFillerTests.cs b/OpenNest.Tests/Fill/ShrinkFillerTests.cs
index e2472f90..69927de2 100644
--- a/OpenNest.Tests/Fill/ShrinkFillerTests.cs
+++ b/OpenNest.Tests/Fill/ShrinkFillerTests.cs
@@ -137,6 +137,42 @@ public class ShrinkFillerTests
Assert.Same(parts, fewer);
}
+ [Fact]
+ public void TrimToCount_KeepsInterlockedPairsWhole()
+ {
+ // Overlapping bounding boxes mark an interlocked pair: (0, 1) and (10, 11).
+ var parts = new List
+ {
+ TestHelpers.MakePartAt(0, 0, 5),
+ TestHelpers.MakePartAt(1, 0, 5),
+ TestHelpers.MakePartAt(10, 0, 5),
+ TestHelpers.MakePartAt(11, 0, 5),
+ TestHelpers.MakePartAt(30, 0, 5),
+ };
+
+ var trimmed = ShrinkFiller.TrimToCount(parts, 3, ShrinkAxis.Width);
+
+ Assert.Equal(new[] { parts[0], parts[1], parts[4] }, trimmed);
+ }
+
+ [Fact]
+ public void TrimToCount_SplitsTheNearestPairOnlyWithoutAnUnpairedPart()
+ {
+ var parts = new List
+ {
+ TestHelpers.MakePartAt(0, 0, 5),
+ TestHelpers.MakePartAt(1, 0, 5),
+ TestHelpers.MakePartAt(10, 0, 5),
+ TestHelpers.MakePartAt(11, 0, 5),
+ TestHelpers.MakePartAt(20, 0, 5),
+ TestHelpers.MakePartAt(21, 0, 5),
+ };
+
+ var trimmed = ShrinkFiller.TrimToCount(parts, 3, ShrinkAxis.Width);
+
+ Assert.Equal(new[] { parts[0], parts[1], parts[2] }, trimmed);
+ }
+
[Fact]
public void TrimToCount_DoesNotMutateInput()
{
diff --git a/docs/performance/fill-verification.md b/docs/performance/fill-verification.md
index 29a08f1e..347c4f8c 100644
--- a/docs/performance/fill-verification.md
+++ b/docs/performance/fill-verification.md
@@ -16,7 +16,9 @@ The category covers comparer, group-pattern, rotated-pattern, extents-column, fe
Irregular's private block proposals opt into quantity-limited Row/Column stripe fills.
`FillLinear.MaxParts` is a soft row limit: the first primary row stays complete, and
perpendicular copies stop after reaching the requested count. The final fill still
-trims its winner to the exact quantity. Existing overlap fallbacks and block spacing
+trims its winner to the exact quantity, keeping or dropping interlocked pairs whole
+(`ShrinkFiller.TrimToCount`; a pair is split only for an odd count with no unpaired part
+left). Existing overlap fallbacks and block spacing
certification remain active; fallback tiling can exceed the soft limit. Nonpositive
limits remain uncapped. Ordinary Fill-strategy fills and the zero-quantity cutout lattice
keep their full-area behavior.