From 4b2aba83bbff50652c9280792bd17070fd4edb75 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Nov 2026 21:17:15 -0500 Subject: [PATCH] feat(fill): keep interlocked pairs whole and place leftovers as pairs A quantity-limited fill trimmed the parts farthest along the trim axis, which could take one half from each of two pairs. TrimToCount now finds interlocked pairs and keeps or drops them whole (from d1d68a91), so the spare space stays one usable cell. After a fill, leftovers now run in two orders, pack-first and pairs-first, and the strategy's comparer keeps the better whole layout, so its ranking of this step cannot get worse. Even quantities go in as best-fit pairs, may turn a quarter when unrestricted, are tried in every remnant and are ranked with the layout around them. Each placed pair slides left and down against true outlines to take the space its bounding box could not. On compare.nest Vertical Remnant goes from 6665.1 to 5862.6 and Lattice from 7200 to 5915.8. Nine characterization pins change from the trim with counts unchanged; the fill pins now also check layout validity. Vertical Remnant's golden layout shortens from 28 to 26. Co-Authored-By: Claude Opus 5.5 --- .../Jobs/FillStackCharacterizationTests.cs | 69 +++-- .../Jobs/GoldenLayoutTests.cs | 28 +- OpenNest.Engine/Fill/InterlockedPairs.cs | 42 +++ OpenNest.Engine/Fill/ShrinkFiller.cs | 35 ++- .../Fillers/PlateFillOrchestrator.cs | 286 +++++++++++++----- OpenNest.Tests/Fill/ShrinkFillerTests.cs | 36 +++ docs/performance/fill-verification.md | 4 +- 7 files changed, 376 insertions(+), 124 deletions(-) create mode 100644 OpenNest.Engine/Fill/InterlockedPairs.cs 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.