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.