From 00e786650682447bf12d188677e18d908ca5aa11 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 21 Mar 2026 15:19:19 -0400 Subject: [PATCH] feat: add remnant filling to PairFiller for better part density PairFiller previously only filled the main grid with pair patterns, leaving narrow waste strips unfilled. Row/Column strategies filled their remnants, winning on count despite worse base grids. Now PairFiller evaluates grid+remnant together for each angle/direction combination, picking the best total. Uses a two-phase approach: fast grid evaluation first, then remnant filling only for grids within striking distance of the current best. Remnant results are cached via FillResultCache. Constructor now takes Plate (needed to create remnant engine). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Fill/PairFiller.cs | 145 +++++++++++++++++- .../Strategies/PairsFillStrategy.cs | 2 +- OpenNest.Tests/PairFillerTests.cs | 14 +- 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index dfddfbb..fa93dd6 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -28,14 +28,16 @@ namespace OpenNest.Engine.Fill private const int EarlyExitMinTried = 10; private const int EarlyExitStaleLimit = 10; + private readonly Plate plate; private readonly Size plateSize; private readonly double partSpacing; private readonly IFillComparer comparer; - public PairFiller(Size plateSize, double partSpacing, IFillComparer comparer = null) + public PairFiller(Plate plate, IFillComparer comparer = null) { - this.plateSize = plateSize; - this.partSpacing = partSpacing; + this.plate = plate; + this.plateSize = plate.Size; + this.partSpacing = plate.PartSpacing; this.comparer = comparer ?? new DefaultFillComparer(); } @@ -73,7 +75,7 @@ namespace OpenNest.Engine.Fill { token.ThrowIfCancellationRequested(); - var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea); + var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea, token); if (comparer.IsBetter(filled, best, effectiveWorkArea)) { @@ -142,12 +144,141 @@ namespace OpenNest.Engine.Fill System.Math.Min(newTop - workArea.Y, workArea.Length)); } - private List EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea) + private List EvaluateCandidate(BestFitResult candidate, Drawing drawing, + Box workArea, CancellationToken token) { var pairParts = candidate.BuildParts(drawing); - var engine = new FillLinear(workArea, partSpacing); var angles = BuildTilingAngles(candidate); - return FillHelpers.FillPattern(engine, pairParts, angles, workArea, comparer); + + // Phase 1: evaluate all grids (fast) + var grids = new List<(List Parts, NestDirection Dir)>(); + foreach (var angle in angles) + { + token.ThrowIfCancellationRequested(); + var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle); + if (pattern.Parts.Count == 0) + continue; + + var engine = new FillLinear(workArea, partSpacing); + foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical }) + { + var gridParts = engine.Fill(pattern, dir); + if (gridParts != null && gridParts.Count > 0) + grids.Add((gridParts, dir)); + } + } + + if (grids.Count == 0) + return null; + + // Sort by count descending so we try the best grids first + grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count)); + + // Phase 2: try remnant for each grid, skip if grid is too far behind + List best = null; + var maxRemnantEstimate = EstimateMaxRemnantParts(drawing, workArea); + + foreach (var (gridParts, dir) in grids) + { + token.ThrowIfCancellationRequested(); + + // If this grid + max possible remnant can't beat current best, skip + if (best != null && gridParts.Count + maxRemnantEstimate <= best.Count) + break; // sorted descending, so remaining are even smaller + + var remnantParts = FillRemnant(gridParts, drawing, workArea, token); + List total; + if (remnantParts != null && remnantParts.Count > 0) + { + total = new List(gridParts.Count + remnantParts.Count); + total.AddRange(gridParts); + total.AddRange(remnantParts); + } + else + { + total = gridParts; + } + + if (comparer.IsBetter(total, best, workArea)) + best = total; + } + + return best; + } + + private static int EstimateMaxRemnantParts(Drawing drawing, Box workArea) + { + var partBox = drawing.Program.BoundingBox(); + var partArea = System.Math.Max(partBox.Width * partBox.Length, 1); + var remnantArea = workArea.Area() * 0.3; // remnant is at most ~30% of work area + return (int)(remnantArea / partArea) + 1; + } + + private List FillRemnant(List gridParts, Drawing drawing, + Box workArea, CancellationToken token) + { + var gridBox = ((IEnumerable)gridParts).GetBoundingBox(); + var partBox = drawing.Program.BoundingBox(); + var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing; + + List bestRemnant = null; + + // Try top remnant (full width, above grid) + var topY = gridBox.Top + partSpacing; + var topLength = workArea.Top - topY; + if (topLength >= minDim) + { + var topBox = new Box(workArea.X, topY, workArea.Width, topLength); + var parts = FillRemnantBox(drawing, topBox, token); + if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) + bestRemnant = parts; + } + + // Try right remnant (full height, right of grid) + var rightX = gridBox.Right + partSpacing; + var rightWidth = workArea.Right - rightX; + if (rightWidth >= minDim) + { + var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length); + var parts = FillRemnantBox(drawing, rightBox, token); + if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) + bestRemnant = parts; + } + + return bestRemnant; + } + + private List FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token) + { + var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing); + if (cachedResult != null) + { + Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts"); + return cachedResult; + } + + FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear"); + try + { + var remnantEngine = NestEngineRegistry.Create(plate); + var item = new NestItem { Drawing = drawing }; + var parts = remnantEngine.Fill(item, remnantBox, null, token); + + Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " + + $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"); + + if (parts != null && parts.Count > 0) + { + FillResultCache.Store(drawing, remnantBox, partSpacing, parts); + return parts; + } + + return null; + } + finally + { + FillStrategyRegistry.SetEnabled(null); + } } private static List BuildTilingAngles(BestFitResult candidate) diff --git a/OpenNest.Engine/Strategies/PairsFillStrategy.cs b/OpenNest.Engine/Strategies/PairsFillStrategy.cs index 1df3ab9..121c7e3 100644 --- a/OpenNest.Engine/Strategies/PairsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/PairsFillStrategy.cs @@ -12,7 +12,7 @@ namespace OpenNest.Engine.Strategies public List Fill(FillContext context) { var comparer = context.Policy?.Comparer; - var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing, comparer); + var filler = new PairFiller(context.Plate, comparer); var result = filler.Fill(context.Item, context.WorkArea, context.PlateNumber, context.Token, context.Progress); diff --git a/OpenNest.Tests/PairFillerTests.cs b/OpenNest.Tests/PairFillerTests.cs index d138e2d..24da18b 100644 --- a/OpenNest.Tests/PairFillerTests.cs +++ b/OpenNest.Tests/PairFillerTests.cs @@ -16,11 +16,15 @@ public class PairFillerTests return new Drawing("rect", pgm); } + private static Plate MakePlate(double width, double length, double spacing = 0.5) + { + return new Plate { Size = new Size(width, length), PartSpacing = spacing }; + } + [Fact] public void Fill_ReturnsPartsForSimpleDrawing() { - var plateSize = new Size(120, 60); - var filler = new PairFiller(plateSize, 0.5); + var filler = new PairFiller(MakePlate(120, 60)); var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var workArea = new Box(0, 0, 120, 60); @@ -33,8 +37,7 @@ public class PairFillerTests [Fact] public void Fill_EmptyResult_WhenPartTooLarge() { - var plateSize = new Size(10, 10); - var filler = new PairFiller(plateSize, 0.5); + var filler = new PairFiller(MakePlate(10, 10)); var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; var workArea = new Box(0, 0, 10, 10); @@ -50,8 +53,7 @@ public class PairFillerTests var cts = new System.Threading.CancellationTokenSource(); cts.Cancel(); - var plateSize = new Size(120, 60); - var filler = new PairFiller(plateSize, 0.5); + var filler = new PairFiller(MakePlate(120, 60)); var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var workArea = new Box(0, 0, 120, 60);