From 0597a11a23ce8a185eb1294f0160ce2917b508ad Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 21 Mar 2026 07:44:59 -0400 Subject: [PATCH] feat: implement StripeFiller.Fill with pair iteration, stripe tiling, and remnant fill Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Fill/StripeFiller.cs | 128 +++++++++++++++++- .../Strategies/StripeFillerTests.cs | 121 +++++++++++++++++ 2 files changed, 247 insertions(+), 2 deletions(-) diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs index 71012d4..7fe8077 100644 --- a/OpenNest.Engine/Fill/StripeFiller.cs +++ b/OpenNest.Engine/Fill/StripeFiller.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Strategies; using OpenNest.Geometry; using OpenNest.Math; @@ -25,8 +26,131 @@ public class StripeFiller public List Fill() { - // Placeholder — implemented in Task 3 - return new List(); + var bestFits = GetPairCandidates(); + if (bestFits.Count == 0) + return new List(); + + var workArea = _context.WorkArea; + var spacing = _context.Plate.PartSpacing; + var drawing = _context.Item.Drawing; + var perpAxis = _primaryAxis == NestDirection.Horizontal + ? NestDirection.Vertical + : NestDirection.Horizontal; + var sheetSpan = GetDimension(workArea, _primaryAxis); + var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column"; + + List bestParts = null; + var bestScore = default(FillScore); + + for (var i = 0; i < bestFits.Count; i++) + { + _context.Token.ThrowIfCancellationRequested(); + + var candidate = bestFits[i]; + var pairParts = candidate.BuildParts(drawing); + + var (angle, waste, count) = ConvergeStripeAngle( + pairParts, sheetSpan, spacing, _primaryAxis, _context.Token); + + if (count <= 0) + continue; + + var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle); + var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis); + var stripeBox = MakeStripeBox(workArea, perpDim, _primaryAxis); + var stripeEngine = new FillLinear(stripeBox, spacing); + var stripeParts = stripeEngine.Fill(rotatedPattern, _primaryAxis); + + if (stripeParts == null || stripeParts.Count == 0) + continue; + + var stripePattern = new Pattern(); + stripePattern.Parts.AddRange(stripeParts); + stripePattern.UpdateBounds(); + + var gridEngine = new FillLinear(workArea, spacing); + var gridParts = gridEngine.Fill(stripePattern, perpAxis); + + if (gridParts == null || gridParts.Count == 0) + continue; + + var allParts = new List(gridParts); + var remnantParts = FillRemnant(gridParts, drawing, angle, workArea, spacing); + if (remnantParts != null) + allParts.AddRange(remnantParts); + + var score = FillScore.Compute(allParts, workArea); + if (bestParts == null || score > bestScore) + { + bestParts = allParts; + bestScore = score; + } + + NestEngineBase.ReportProgress(_context.Progress, NestPhase.Custom, + _context.PlateNumber, bestParts, workArea, + $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestScore.Count} parts"); + } + + return bestParts ?? new List(); + } + + private List GetPairCandidates() + { + List bestFits; + + if (_context.SharedState.TryGetValue("BestFits", out var cached)) + bestFits = (List)cached; + else + bestFits = BestFitCache.GetOrCompute( + _context.Item.Drawing, + _context.Plate.Size.Length, + _context.Plate.Size.Width, + _context.Plate.PartSpacing); + + return bestFits + .Where(r => r.Keep) + .Take(MaxPairCandidates) + .ToList(); + } + + private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis) + { + return primaryAxis == NestDirection.Horizontal + ? new Box(workArea.X, workArea.Y, workArea.Width, perpDim) + : new Box(workArea.X, workArea.Y, perpDim, workArea.Length); + } + + private List FillRemnant( + List gridParts, Drawing drawing, double angle, + Box workArea, double spacing) + { + var gridBox = gridParts.GetBoundingBox(); + var minDim = System.Math.Min( + drawing.Program.BoundingBox().Width, + drawing.Program.BoundingBox().Length); + + Box remnantBox; + + if (_primaryAxis == NestDirection.Horizontal) + { + var remnantY = gridBox.Top + spacing; + var remnantLength = workArea.Top - remnantY; + if (remnantLength < minDim) + return null; + remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength); + } + else + { + var remnantX = gridBox.Right + spacing; + var remnantWidth = workArea.Right - remnantX; + if (remnantWidth < minDim) + return null; + remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length); + } + + var engine = new FillLinear(remnantBox, spacing); + var parts = engine.Fill(drawing, angle, _primaryAxis); + return parts != null && parts.Count > 0 ? parts : null; } public static double FindAngleForTargetSpan( diff --git a/OpenNest.Tests/Strategies/StripeFillerTests.cs b/OpenNest.Tests/Strategies/StripeFillerTests.cs index 02a669a..dec647b 100644 --- a/OpenNest.Tests/Strategies/StripeFillerTests.cs +++ b/OpenNest.Tests/Strategies/StripeFillerTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Fill; using OpenNest.Engine.Strategies; using OpenNest.Geometry; @@ -27,6 +29,43 @@ public class StripeFillerTests return pattern; } + /// + /// Builds a simple side-by-side pair BestFitResult for a rectangular drawing. + /// Places two copies next to each other along the X axis with the given spacing. + /// + private static List MakeSideBySideBestFits( + Drawing drawing, double spacing) + { + var bb = drawing.Program.BoundingBox(); + var w = bb.Width; + var h = bb.Length; + + var candidate = new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0, + Part2Rotation = 0, + Part2Offset = new Vector(w + spacing, 0), + Spacing = spacing, + }; + + var pairWidth = 2 * w + spacing; + var result = new BestFitResult + { + Candidate = candidate, + BoundingWidth = pairWidth, + BoundingHeight = h, + RotatedArea = pairWidth * h, + TrueArea = 2 * w * h, + OptimalRotation = 0, + Keep = true, + Reason = "Valid", + HullAngles = new List(), + }; + + return new List { result }; + } + [Fact] public void FindAngleForTargetSpan_ZeroAngle_WhenAlreadyMatches() { @@ -92,4 +131,86 @@ public class StripeFillerTests Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}"); } + + [Fact] + public void Fill_ProducesPartsForSimpleDrawing() + { + var plate = new Plate(60, 120) { PartSpacing = 0.5 }; + var drawing = MakeRectDrawing(20, 10); + var item = new NestItem { Drawing = drawing }; + var workArea = new Box(0, 0, 120, 60); + + var bestFits = MakeSideBySideBestFits(drawing, 0.5); + + var context = new OpenNest.Engine.Strategies.FillContext + { + Item = item, + WorkArea = workArea, + Plate = plate, + PlateNumber = 0, + Token = System.Threading.CancellationToken.None, + Progress = null, + }; + context.SharedState["BestFits"] = bestFits; + + var filler = new StripeFiller(context, NestDirection.Horizontal); + var parts = filler.Fill(); + + Assert.NotNull(parts); + Assert.True(parts.Count > 0, "Expected parts from stripe fill"); + } + + [Fact] + public void Fill_VerticalProducesParts() + { + var plate = new Plate(60, 120) { PartSpacing = 0.5 }; + var drawing = MakeRectDrawing(20, 10); + var item = new NestItem { Drawing = drawing }; + var workArea = new Box(0, 0, 120, 60); + + var bestFits = MakeSideBySideBestFits(drawing, 0.5); + + var context = new OpenNest.Engine.Strategies.FillContext + { + Item = item, + WorkArea = workArea, + Plate = plate, + PlateNumber = 0, + Token = System.Threading.CancellationToken.None, + Progress = null, + }; + context.SharedState["BestFits"] = bestFits; + + var filler = new StripeFiller(context, NestDirection.Vertical); + var parts = filler.Fill(); + + Assert.NotNull(parts); + Assert.True(parts.Count > 0, "Expected parts from column fill"); + } + + [Fact] + public void Fill_ReturnsEmpty_WhenNoBestFits() + { + var plate = new Plate(60, 120) { PartSpacing = 0.5 }; + var drawing = MakeRectDrawing(20, 10); + var item = new NestItem { Drawing = drawing }; + var workArea = new Box(0, 0, 120, 60); + + var context = new OpenNest.Engine.Strategies.FillContext + { + Item = item, + WorkArea = workArea, + Plate = plate, + PlateNumber = 0, + Token = System.Threading.CancellationToken.None, + Progress = null, + }; + context.SharedState["BestFits"] = new List(); + + var filler = new StripeFiller(context, NestDirection.Horizontal); + var parts = filler.Fill(); + + Assert.NotNull(parts); + Assert.Empty(parts); + } }