From e969260f3d5327f79dc5efb4393a7f651dd0d2bf Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Mar 2026 15:53:23 -0400 Subject: [PATCH] refactor(engine): introduce PairFillResult and remove FillRemainingStrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PairFiller now returns PairFillResult (Parts + BestFits) instead of using a mutable BestFits property. Extracted EvaluateCandidates, TryReduceWorkArea, and BuildTilingAngles for clarity. Simplified the candidate loop by leveraging FillScore comparison semantics. Removed FillRemainingStrip and all its helpers (FindPlacedEdge, BuildRemainingStrip, BuildRotationSet, FindBestFill, TryFewerRows, RemainderPatterns) from FillLinear — these were a major bottleneck in strip nesting, running expensive fills on undersized remnant strips. ShrinkFiller + RemnantFiller already handle space optimization, making the remainder strip fill redundant. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Fill/FillLinear.cs | 211 ------------------ OpenNest.Engine/Fill/IterativeShrinkFiller.cs | 192 +++++++++++++++- OpenNest.Engine/Fill/PairFiller.cs | 129 +++++++---- OpenNest.Engine/Fill/RemnantFiller.cs | 146 +++++++----- .../Strategies/PairsFillStrategy.cs | 4 +- OpenNest.Engine/StripNestEngine.cs | 14 +- OpenNest.Tests/PairFillerTests.cs | 18 +- 7 files changed, 380 insertions(+), 334 deletions(-) diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index 4a09190..97b5dc3 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -19,11 +19,6 @@ namespace OpenNest.Engine.Fill public double HalfSpacing => PartSpacing / 2; - /// - /// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips. - /// - public List RemainderPatterns { get; set; } - private static Vector MakeOffset(NestDirection direction, double distance) { return direction == NestDirection.Horizontal @@ -346,215 +341,9 @@ namespace OpenNest.Engine.Fill var gridResult = new List(rowPattern.Parts); gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries)); - // Step 3: Fill remaining strip - var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction); - if (remaining.Count > 0) - gridResult.AddRange(remaining); - - // Step 4: Try fewer rows optimization - var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction); - if (fewerResult != null && fewerResult.Count > gridResult.Count) - return fewerResult; - return gridResult; } - /// - /// Tries removing the last row/column from the grid and re-filling the - /// larger remainder strip. Returns null if this doesn't improve the total. - /// - private List TryFewerRows( - List fullResult, Pattern rowPattern, Pattern seedPattern, - NestDirection tiledAxis, NestDirection primaryAxis) - { - var rowPartCount = rowPattern.Parts.Count; - - if (fullResult.Count < rowPartCount * 2) - return null; - - var fewerParts = new List(fullResult.Count - rowPartCount); - - for (var i = 0; i < fullResult.Count - rowPartCount; i++) - fewerParts.Add(fullResult[i]); - - var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis); - - if (remaining.Count <= rowPartCount) - return null; - - fewerParts.AddRange(remaining); - return fewerParts; - } - - /// - /// After tiling full rows/columns, fills the remaining strip with individual - /// parts. The strip is the leftover space along the tiled axis between the - /// last full row/column and the work area boundary. Each unique drawing and - /// rotation from the seed pattern is tried in both directions. - /// - private List FillRemainingStrip( - List placedParts, Pattern seedPattern, - NestDirection tiledAxis, NestDirection primaryAxis) - { - var placedEdge = FindPlacedEdge(placedParts, tiledAxis); - var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis); - - if (remainingStrip == null) - return new List(); - - var rotations = BuildRotationSet(seedPattern); - var best = FindBestFill(rotations, remainingStrip); - - if (RemainderPatterns != null) - { - System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns"); - - foreach (var pattern in RemainderPatterns) - { - var filler = new FillLinear(remainingStrip, PartSpacing); - var h = filler.Fill(pattern, NestDirection.Horizontal); - var v = filler.Fill(pattern, NestDirection.Vertical); - - System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}"); - - if (h != null && h.Count > (best?.Count ?? 0)) - best = h; - if (v != null && v.Count > (best?.Count ?? 0)) - best = v; - } - - System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}"); - } - - return best ?? new List(); - } - - private static double FindPlacedEdge(List placedParts, NestDirection tiledAxis) - { - var placedEdge = double.MinValue; - - foreach (var part in placedParts) - { - var edge = tiledAxis == NestDirection.Vertical - ? part.BoundingBox.Top - : part.BoundingBox.Right; - - if (edge > placedEdge) - placedEdge = edge; - } - - return placedEdge; - } - - private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis) - { - if (tiledAxis == NestDirection.Vertical) - { - var bottom = placedEdge + PartSpacing; - var height = WorkArea.Top - bottom; - - if (height <= Tolerance.Epsilon) - return null; - - return new Box(WorkArea.X, bottom, WorkArea.Width, height); - } - else - { - var left = placedEdge + PartSpacing; - var width = WorkArea.Right - left; - - if (width <= Tolerance.Epsilon) - return null; - - return new Box(left, WorkArea.Y, width, WorkArea.Length); - } - } - - /// - /// Builds a set of (drawing, rotation) candidates: cardinal orientations - /// (0° and 90°) for each unique drawing, plus any seed pattern rotations - /// not already covered. - /// - private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern) - { - var rotations = new List<(Drawing drawing, double rotation)>(); - var drawings = new List(); - - foreach (var seedPart in seedPattern.Parts) - { - var found = false; - - foreach (var d in drawings) - { - if (d == seedPart.BaseDrawing) - { - found = true; - break; - } - } - - if (!found) - drawings.Add(seedPart.BaseDrawing); - } - - foreach (var drawing in drawings) - { - rotations.Add((drawing, 0)); - rotations.Add((drawing, Angle.HalfPI)); - } - - foreach (var seedPart in seedPattern.Parts) - { - var skip = false; - - foreach (var (d, r) in rotations) - { - if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation)) - { - skip = true; - break; - } - } - - if (!skip) - rotations.Add((seedPart.BaseDrawing, seedPart.Rotation)); - } - - return rotations; - } - - /// - /// Tries all rotation candidates in both directions in parallel, returns the - /// fill with the most parts. - /// - private List FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip) - { - var bag = new System.Collections.Concurrent.ConcurrentBag>(); - - Parallel.ForEach(rotations, entry => - { - var filler = new FillLinear(strip, PartSpacing); - var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); - var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); - - if (h != null && h.Count > 0) - bag.Add(h); - - if (v != null && v.Count > 0) - bag.Add(v); - }); - - List best = null; - - foreach (var candidate in bag) - { - if (best == null || candidate.Count > best.Count) - best = candidate; - } - - return best ?? new List(); - } - /// /// Fills a single row of identical parts along one axis using geometry-aware spacing. /// diff --git a/OpenNest.Engine/Fill/IterativeShrinkFiller.cs b/OpenNest.Engine/Fill/IterativeShrinkFiller.cs index 37255ba..e6de8b2 100644 --- a/OpenNest.Engine/Fill/IterativeShrinkFiller.cs +++ b/OpenNest.Engine/Fill/IterativeShrinkFiller.cs @@ -2,6 +2,7 @@ using OpenNest.Geometry; using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; namespace OpenNest.Engine.Fill { @@ -74,16 +75,33 @@ namespace OpenNest.Engine.Fill Func> shrinkWrapper = (ni, box) => { var target = ni.Quantity > 0 ? ni.Quantity : 0; - var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token, - targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar); - var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token, - targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar); + + // Run height and width shrinks in parallel — they are independent + // (same inputs, no shared mutable state). + ShrinkResult heightResult = null; + ShrinkResult widthResult = null; + + Parallel.Invoke( + () => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token, + targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar), + () => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token, + targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar) + ); var heightScore = FillScore.Compute(heightResult.Parts, box); var widthScore = FillScore.Compute(widthResult.Parts, box); var best = widthScore > heightScore ? widthResult.Parts : heightResult.Parts; + // Sort pair groups so shorter/narrower groups are closer to the origin, + // creating a staircase profile that maximizes remnant area. + // Height shrink → columns vary in height → sort columns. + // Width shrink → rows vary in width → sort rows. + if (widthScore > heightScore) + SortRowsByWidth(best, spacing); + else + SortColumnsByHeight(best, spacing); + // Report the winner as overall best so the UI shows it as settled. if (progress != null && best != null && best.Count > 0) { @@ -134,5 +152,171 @@ namespace OpenNest.Engine.Fill return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers }; } + + /// + /// Sorts pair columns by height (shortest first on the left) to create + /// a staircase profile that maximizes usable remnant area. + /// + internal static void SortColumnsByHeight(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return; + + // Sort parts by Left edge for grouping. + parts.Sort((a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left)); + + // Group parts into columns by X overlap. + var columns = new List>(); + var column = new List { parts[0] }; + var columnRight = parts[0].BoundingBox.Right; + + for (var i = 1; i < parts.Count; i++) + { + if (parts[i].BoundingBox.Left > columnRight + spacing / 2) + { + columns.Add(column); + column = new List { parts[i] }; + columnRight = parts[i].BoundingBox.Right; + } + else + { + column.Add(parts[i]); + if (parts[i].BoundingBox.Right > columnRight) + columnRight = parts[i].BoundingBox.Right; + } + } + columns.Add(column); + + if (columns.Count <= 1) + return; + + // Measure inter-column gap from original layout. + var gap = MinLeft(columns[1]) - MaxRight(columns[0]); + + // Sort columns by height ascending (shortest first). + columns.Sort((a, b) => MaxTop(a).CompareTo(MaxTop(b))); + + // Reposition columns left-to-right. + var x = parts[0].BoundingBox.Left; // parts already sorted by Left + + foreach (var col in columns) + { + var colLeft = MinLeft(col); + var dx = x - colLeft; + + if (System.Math.Abs(dx) > OpenNest.Math.Tolerance.Epsilon) + { + var offset = new Vector(dx, 0); + foreach (var part in col) + part.Offset(offset); + } + + x = MaxRight(col) + gap; + } + + // Rebuild the parts list in column order. + parts.Clear(); + foreach (var col in columns) + parts.AddRange(col); + } + + /// + /// Sorts pair rows by width (narrowest first on the bottom) to create + /// a staircase profile on the right side that maximizes usable remnant area. + /// + internal static void SortRowsByWidth(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return; + + // Sort parts by Bottom edge for grouping. + parts.Sort((a, b) => a.BoundingBox.Bottom.CompareTo(b.BoundingBox.Bottom)); + + // Group parts into rows by Y overlap. + var rows = new List>(); + var row = new List { parts[0] }; + var rowTop = parts[0].BoundingBox.Top; + + for (var i = 1; i < parts.Count; i++) + { + if (parts[i].BoundingBox.Bottom > rowTop + spacing / 2) + { + rows.Add(row); + row = new List { parts[i] }; + rowTop = parts[i].BoundingBox.Top; + } + else + { + row.Add(parts[i]); + if (parts[i].BoundingBox.Top > rowTop) + rowTop = parts[i].BoundingBox.Top; + } + } + rows.Add(row); + + if (rows.Count <= 1) + return; + + // Measure inter-row gap from original layout. + var gap = MinBottom(rows[1]) - MaxTop(rows[0]); + + // Sort rows by width ascending (narrowest first). + rows.Sort((a, b) => MaxRight(a).CompareTo(MaxRight(b))); + + // Reposition rows bottom-to-top. + var y = parts[0].BoundingBox.Bottom; // parts already sorted by Bottom + + foreach (var r in rows) + { + var rowBottom = MinBottom(r); + var dy = y - rowBottom; + + if (System.Math.Abs(dy) > OpenNest.Math.Tolerance.Epsilon) + { + var offset = new Vector(0, dy); + foreach (var part in r) + part.Offset(offset); + } + + y = MaxTop(r) + gap; + } + + // Rebuild the parts list in row order. + parts.Clear(); + foreach (var r in rows) + parts.AddRange(r); + } + + private static double MaxTop(List col) + { + var max = double.MinValue; + foreach (var p in col) + if (p.BoundingBox.Top > max) max = p.BoundingBox.Top; + return max; + } + + private static double MaxRight(List col) + { + var max = double.MinValue; + foreach (var p in col) + if (p.BoundingBox.Right > max) max = p.BoundingBox.Right; + return max; + } + + private static double MinLeft(List col) + { + var min = double.MaxValue; + foreach (var p in col) + if (p.BoundingBox.Left < min) min = p.BoundingBox.Left; + return min; + } + + private static double MinBottom(List row) + { + var min = double.MaxValue; + foreach (var p in row) + if (p.BoundingBox.Bottom < min) min = p.BoundingBox.Bottom; + return min; + } } } diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index 33e6fc3..ba620b6 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -10,6 +10,12 @@ using System.Threading; namespace OpenNest.Engine.Fill { + public class PairFillResult + { + public List Parts { get; set; } = new List(); + public List BestFits { get; set; } + } + /// /// Fills a work area using interlocking part pairs from BestFitCache. /// @@ -24,36 +30,40 @@ namespace OpenNest.Engine.Fill private readonly Size plateSize; private readonly double partSpacing; - /// - /// The best-fit results computed during the last Fill call. - /// Available after Fill returns so callers can reuse without recomputing. - /// - public List BestFits { get; private set; } - public PairFiller(Size plateSize, double partSpacing) { this.plateSize = plateSize; this.partSpacing = partSpacing; } - public List Fill(NestItem item, Box workArea, + public PairFillResult Fill(NestItem item, Box workArea, int plateNumber = 0, CancellationToken token = default, IProgress progress = null) { - BestFits = BestFitCache.GetOrCompute( + var bestFits = BestFitCache.GetOrCompute( item.Drawing, plateSize.Length, plateSize.Width, partSpacing); - var candidates = SelectPairCandidates(BestFits, workArea); - Debug.WriteLine($"[PairFiller] Total: {BestFits.Count}, Kept: {BestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + var candidates = SelectPairCandidates(bestFits, workArea); + Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); Debug.WriteLine($"[PairFiller] Plate: {plateSize.Length:F2}x{plateSize.Width:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"); var targetCount = item.Quantity > 0 ? item.Quantity : 0; - var effectiveWorkArea = workArea; + var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount, + plateNumber, token, progress); + return new PairFillResult { Parts = parts, BestFits = bestFits }; + } + + private List EvaluateCandidates( + List candidates, Drawing drawing, + Box workArea, int targetCount, + int plateNumber, CancellationToken token, IProgress progress) + { List best = null; var bestScore = default(FillScore); var sinceImproved = 0; + var effectiveWorkArea = workArea; try { @@ -61,34 +71,15 @@ namespace OpenNest.Engine.Fill { token.ThrowIfCancellationRequested(); - var filled = EvaluateCandidate(candidates[i], item.Drawing, effectiveWorkArea); + var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea); + var score = FillScore.Compute(filled, effectiveWorkArea); - if (filled != null && filled.Count > 0) + if (score > bestScore) { - var score = FillScore.Compute(filled, effectiveWorkArea); - if (best == null || score > bestScore) - { - best = filled; - bestScore = score; - sinceImproved = 0; - - // If we exceeded the target, reduce the work area for - // subsequent candidates by trimming excess parts and - // measuring the tighter bounding box. - if (targetCount > 0 && filled.Count > targetCount) - { - var reduced = ReduceWorkArea(filled, targetCount, workArea); - if (reduced.Area() < effectiveWorkArea.Area()) - { - effectiveWorkArea = reduced; - Debug.WriteLine($"[PairFiller] Reduced work area to {effectiveWorkArea.Width:F2}x{effectiveWorkArea.Length:F2} (trimmed to {targetCount + 1} parts)"); - } - } - } - else - { - sinceImproved++; - } + best = filled; + bestScore = score; + sinceImproved = 0; + effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea); } else { @@ -114,6 +105,19 @@ namespace OpenNest.Engine.Fill return best ?? new List(); } + private static Box TryReduceWorkArea(List parts, int targetCount, Box workArea, Box effectiveWorkArea) + { + if (targetCount <= 0 || parts.Count <= targetCount) + return effectiveWorkArea; + + var reduced = ReduceWorkArea(parts, targetCount, workArea); + if (reduced.Area() >= effectiveWorkArea.Area()) + return effectiveWorkArea; + + Debug.WriteLine($"[PairFiller] Reduced work area to {reduced.Width:F2}x{reduced.Length:F2} (trimmed to {targetCount + 1} parts)"); + return reduced; + } + /// /// Given parts that exceed targetCount, sorts by BoundingBox.Top descending, /// removes parts from the top until exactly targetCount remain, then returns @@ -124,12 +128,10 @@ namespace OpenNest.Engine.Fill if (parts.Count <= targetCount) return workArea; - // Sort by Top descending — highest parts get trimmed first. var sorted = parts .OrderByDescending(p => p.BoundingBox.Top) .ToList(); - // Remove from the top until exactly targetCount remain. var trimCount = sorted.Count - targetCount; var remaining = sorted.Skip(trimCount).ToList(); @@ -144,22 +146,23 @@ namespace OpenNest.Engine.Fill { var pairParts = candidate.BuildParts(drawing); var engine = new FillLinear(workArea, partSpacing); + var angles = BuildTilingAngles(candidate); + return FillHelpers.FillPattern(engine, pairParts, angles, workArea); + } - var p0 = FillHelpers.BuildRotatedPattern(pairParts, 0); - var p90 = FillHelpers.BuildRotatedPattern(pairParts, Angle.HalfPI); - engine.RemainderPatterns = new List { p0, p90 }; - - // Include the pair's rotating calipers optimal rotation angle - // alongside the hull edge angles for tiling. + private static List BuildTilingAngles(BestFitResult candidate) + { var angles = new List(candidate.HullAngles); var optAngle = -candidate.OptimalRotation; + if (!angles.Any(a => a.IsEqualTo(optAngle))) angles.Add(optAngle); + var optAngle90 = Angle.NormalizeRad(optAngle + Angle.HalfPI); if (!angles.Any(a => a.IsEqualTo(optAngle90))) angles.Add(optAngle90); - return FillHelpers.FillPattern(engine, pairParts, angles, workArea); + return angles; } private List SelectPairCandidates(List bestFits, Box workArea) @@ -191,7 +194,41 @@ namespace OpenNest.Engine.Fill Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); } + SortByEstimatedCount(top, workArea); + return top; } + + private void SortByEstimatedCount(List candidates, Box workArea) + { + var w = workArea.Width; + var l = workArea.Length; + + candidates.Sort((a, b) => + { + var aCount = EstimateTileCount(a, w, l); + var bCount = EstimateTileCount(b, w, l); + + if (aCount != bCount) + return bCount.CompareTo(aCount); + + return b.Utilization.CompareTo(a.Utilization); + }); + } + + private int EstimateTileCount(BestFitResult r, double areaW, double areaL) + { + var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL); + var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL); + return System.Math.Max(h, v); + } + + private int EstimateCount(double pairW, double pairH, double areaW, double areaL) + { + if (pairW <= 0 || pairH <= 0) return 0; + var cols = (int)((areaW + partSpacing) / (pairW + partSpacing)); + var rows = (int)((areaL + partSpacing) / (pairH + partSpacing)); + return cols * rows * 2; + } } } diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs index 94d8b98..9251b82 100644 --- a/OpenNest.Engine/Fill/RemnantFiller.cs +++ b/OpenNest.Engine/Fill/RemnantFiller.cs @@ -37,76 +37,106 @@ namespace OpenNest.Engine.Fill return new List(); var allParts = new List(); - var madeProgress = true; // Track quantities locally — do not mutate the input NestItem objects. - var localQty = new Dictionary(); - foreach (var item in items) - localQty[item.Drawing.Name] = item.Quantity; + var localQty = BuildLocalQuantities(items); - while (madeProgress && !token.IsCancellationRequested) + while (!token.IsCancellationRequested) { - madeProgress = false; - - var minRemnantDim = double.MaxValue; - foreach (var item in items) - { - var qty = localQty[item.Drawing.Name]; - if (qty <= 0) - continue; - var bb = item.Drawing.Program.BoundingBox(); - var dim = System.Math.Min(bb.Width, bb.Length); - if (dim < minRemnantDim) - minRemnantDim = dim; - } - - if (minRemnantDim == double.MaxValue) + var minDim = FindMinItemDimension(items, localQty); + if (minDim == double.MaxValue) break; - var freeBoxes = finder.FindRemnants(minRemnantDim); - + var freeBoxes = finder.FindRemnants(minDim); if (freeBoxes.Count == 0) break; - foreach (var item in items) - { - if (token.IsCancellationRequested) - break; - - var qty = localQty[item.Drawing.Name]; - if (qty == 0) - continue; - - var itemBbox = item.Drawing.Program.BoundingBox(); - var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length); - - foreach (var box in freeBoxes) - { - if (System.Math.Min(box.Width, box.Length) < minItemDim) - continue; - - var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty }; - var remnantParts = fillFunc(fillItem, box); - - if (remnantParts != null && remnantParts.Count > 0) - { - allParts.AddRange(remnantParts); - localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count); - - foreach (var p in remnantParts) - finder.AddObstacle(p.BoundingBox.Offset(spacing)); - - madeProgress = true; - break; - } - } - - if (madeProgress) - break; - } + if (!TryFillOneItem(items, freeBoxes, localQty, fillFunc, allParts, token)) + break; } return allParts; } + + private static Dictionary BuildLocalQuantities(List items) + { + var localQty = new Dictionary(items.Count); + foreach (var item in items) + localQty[item.Drawing.Name] = item.Quantity; + return localQty; + } + + private static double FindMinItemDimension(List items, Dictionary localQty) + { + var minDim = double.MaxValue; + foreach (var item in items) + { + if (localQty[item.Drawing.Name] <= 0) + continue; + var bb = item.Drawing.Program.BoundingBox(); + var dim = System.Math.Min(bb.Width, bb.Length); + if (dim < minDim) + minDim = dim; + } + return minDim; + } + + private bool TryFillOneItem( + List items, + List freeBoxes, + Dictionary localQty, + Func> fillFunc, + List allParts, + CancellationToken token) + { + foreach (var item in items) + { + if (token.IsCancellationRequested) + return false; + + var qty = localQty[item.Drawing.Name]; + if (qty <= 0) + continue; + + var placed = TryFillInRemnants(item, qty, freeBoxes, fillFunc); + if (placed == null) + continue; + + allParts.AddRange(placed); + localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count); + + foreach (var p in placed) + finder.AddObstacle(p.BoundingBox.Offset(spacing)); + + return true; + } + + return false; + } + + private static List TryFillInRemnants( + NestItem item, + int qty, + List freeBoxes, + Func> fillFunc) + { + var itemBbox = item.Drawing.Program.BoundingBox(); + + foreach (var box in freeBoxes) + { + var fitsNormal = box.Width >= itemBbox.Width && box.Length >= itemBbox.Length; + var fitsRotated = box.Width >= itemBbox.Length && box.Length >= itemBbox.Width; + if (!fitsNormal && !fitsRotated) + continue; + + var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty }; + var parts = fillFunc(fillItem, box); + + if (parts != null && parts.Count > 0) + return parts; + } + + return null; + } } } diff --git a/OpenNest.Engine/Strategies/PairsFillStrategy.cs b/OpenNest.Engine/Strategies/PairsFillStrategy.cs index 118c732..862390e 100644 --- a/OpenNest.Engine/Strategies/PairsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/PairsFillStrategy.cs @@ -15,9 +15,9 @@ namespace OpenNest.Engine.Strategies var result = filler.Fill(context.Item, context.WorkArea, context.PlateNumber, context.Token, context.Progress); - context.SharedState["BestFits"] = filler.BestFits; + context.SharedState["BestFits"] = result.BestFits; - return result; + return result.Parts; } } } diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 675307c..efe69e2 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -77,12 +77,12 @@ namespace OpenNest // Phase 1: Iterative shrink-fill for multi-quantity items. if (fillItems.Count > 0) { - // Inner fills are silent — ShrinkFiller manages progress reporting - // to avoid overlapping layouts from per-angle/per-strategy reports. + // Pass progress through so the UI shows intermediate results + // during the initial BestFitCache computation and fill phases. Func> fillFunc = (ni, b) => { var inner = new DefaultNestEngine(Plate); - return inner.Fill(ni, b, null, token); + return inner.Fill(ni, b, progress, token); }; var shrinkResult = IterativeShrinkFiller.Fill( @@ -91,6 +91,14 @@ namespace OpenNest allParts.AddRange(shrinkResult.Parts); + // Compact placed parts toward the origin to close gaps. + if (allParts.Count > 1) + { + var noObstacles = new List(); + Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left); + Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down); + } + // Add unfilled items to pack list. packItems.AddRange(shrinkResult.Leftovers); } diff --git a/OpenNest.Tests/PairFillerTests.cs b/OpenNest.Tests/PairFillerTests.cs index 06c654d..d138e2d 100644 --- a/OpenNest.Tests/PairFillerTests.cs +++ b/OpenNest.Tests/PairFillerTests.cs @@ -24,11 +24,10 @@ public class PairFillerTests var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var workArea = new Box(0, 0, 120, 60); - var parts = filler.Fill(item, workArea); + var result = filler.Fill(item, workArea); - Assert.NotNull(parts); - // Pair filling may or may not find interlocking pairs for rectangles, - // but should return a non-null list. + Assert.NotNull(result.Parts); + Assert.NotNull(result.BestFits); } [Fact] @@ -39,10 +38,10 @@ public class PairFillerTests var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; var workArea = new Box(0, 0, 10, 10); - var parts = filler.Fill(item, workArea); + var result = filler.Fill(item, workArea); - Assert.NotNull(parts); - Assert.Empty(parts); + Assert.NotNull(result.Parts); + Assert.Empty(result.Parts); } [Fact] @@ -56,9 +55,8 @@ public class PairFillerTests var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; var workArea = new Box(0, 0, 120, 60); - var parts = filler.Fill(item, workArea, token: cts.Token); + var result = filler.Fill(item, workArea, token: cts.Token); - // Should return empty or partial — not throw - Assert.NotNull(parts); + Assert.NotNull(result.Parts); } }