diff --git a/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs b/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs new file mode 100644 index 00000000..86093a39 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/GussetColumnFillTests.cs @@ -0,0 +1,51 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// A reported nest: 8" gussets (right triangles with clipped corners and a 2.2" hole) on a +/// 16.85 x 60 strip. Fill tiled 7 gusset pairs in a 10.35" column (14) and left a strip too +/// narrow for a gusset square on. Sliding each pair along its hypotenuse so 6 span the height +/// narrows the column to 8.75" and leaves a strip that holds 8 more turned 45 degrees (20), as +/// the user laid it out by hand. +/// +public class GussetColumnFillTests +{ + private static Plate GussetPlate() => + new(new Size(60, 16.85)) + { + PartSpacing = 0.25, + EdgeSpacing = new Spacing(0.5, 0.25, 0.5, 0.25), + }; + + private static Drawing Gusset() + { + var program = new Program(); + program.MoveTo(3.7, 2.6); + program.ArcTo(new Vector(3.7, 2.6), new Vector(2.6, 2.6), RotationType.CCW); + program.MoveTo(0, 0); + foreach (var (x, y) in new[] { (0.0, 8.0), (1.0, 8.0), (8.0, 1.0), (8.0, 0.0), (0.0, 0.0) }) + program.LineTo(x, y); + return new Drawing("Gusset", program); + } + + [Theory] + [InlineData("Fill")] + [InlineData("Vertical Remnant")] + public void FillSlidesThePairColumnToFitTurnedGussetsBesideIt(string strategy) + { + var plate = GussetPlate(); + var drawing = Gusset(); + + var parts = PlateFillService.FillItem(strategy, plate, + new NestItem { Drawing = drawing, Quantity = 0 }, plate.WorkArea(), null, + CancellationToken.None); + + Assert.Equal(20, parts.Count); + Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) }, + new Dictionary { [drawing] = ("Gusset", 20) })); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs index 2f32dcbf..e9837f98 100644 --- a/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs +++ b/OpenNest.Engine.Tests/Jobs/FillStackCharacterizationTests.cs @@ -33,6 +33,10 @@ namespace OpenNest.Engine.Tests.Jobs; /// The learned-angle nests were re-captured when the best-fit aspect limit began allowing a /// part's own aspect: the long bar now nests as an interlocked pair, and the notched quantity /// dropped from 6 to 4 so the Fill nest still differs from a full sweep. +/// The mixed Fill and Strip nests were re-captured when the pairs phase began tiling its leftover +/// strip with pair grids (and strips narrower than the part's bounding box) and trying skipped +/// candidates whose remnant could win: Fill now places 34 instead of 33, and Strip keeps 35 with +/// its L-shapes laid out differently (both layouts pass NestLayoutCheck). /// 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 @@ -276,8 +280,8 @@ public class FillStackCharacterizationTests } [Theory] - [InlineData("Fill", 33, "5F41788D261A5085")] - [InlineData("Strip", 35, "865B16D4365A2492")] + [InlineData("Fill", 34, "D3D54E9116D96ACF")] + [InlineData("Strip", 35, "D5A35DDDE6A5B36A")] [InlineData("Vertical Remnant", 35, "8CC17C7708C8049F")] [InlineData("Horizontal Remnant", 30, "A0700052AE771E06")] public void Nest_MixedIrregularItems_PinnedLayout( diff --git a/OpenNest.Engine/Fill/PairCandidateSelector.cs b/OpenNest.Engine/Fill/PairCandidateSelector.cs index 5031dabe..1d42abf1 100644 --- a/OpenNest.Engine/Fill/PairCandidateSelector.cs +++ b/OpenNest.Engine/Fill/PairCandidateSelector.cs @@ -77,6 +77,44 @@ namespace OpenNest.Engine.Fill ); } + /// + /// An optimistic count for the candidate's grid plus its leftover strips: each strip at + /// least wide (the narrowest the part fits turned) is packed + /// at of parts of . + /// + public int EstimateWithRemnant( + BestFitResult r, + Box workArea, + double minWidth, + double maxUtilization, + double partArea + ) + { + int Estimate(double pairW, double pairH) + { + if (pairW <= 0 || pairH <= 0 || partArea <= 0) + return 0; + + var areaW = workArea.Width; + var areaL = workArea.Length; + var cols = (int)((areaW + partSpacing) / (pairW + partSpacing)); + var rows = (int)((areaL + partSpacing) / (pairH + partSpacing)); + if (cols == 0 || rows == 0) + return 0; + + var leftW = areaW - cols * (pairW + partSpacing); + var leftL = areaL - rows * (pairH + partSpacing); + var strips = (leftW >= minWidth ? leftW * areaL : 0) + + (leftL >= minWidth ? leftL * areaW : 0); + return cols * rows * 2 + (int)(strips * maxUtilization / partArea); + } + + return System.Math.Max( + Estimate(r.BoundingWidth, r.BoundingHeight), + Estimate(r.BoundingHeight, r.BoundingWidth) + ); + } + private int EstimateTileCount(BestFitResult r, double areaW, double areaL) { var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL); diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index c50323a9..2dbfdf9b 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -185,18 +185,26 @@ namespace OpenNest.Engine.Fill var effectiveWorkArea = workArea; var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0; + // The remnant bound packs parts at the best pair utilization, which is measured against + // the part's true area: its bounding box would undercount parts that interlock, such + // as gussets, and prune candidates whose remnant could win. var partBox = drawing.Program.BoundingBox(); - var partArea = System.Math.Max(partBox.Width * partBox.Length, 1); + var partArea = drawing.Area > 0 + ? drawing.Area + : System.Math.Max(partBox.Width * partBox.Length, 1); - try + // Evaluates the batches of a candidate list; with early exit, stops once that many + // candidates in a row failed to improve and returns where it stopped. With requireMore, + // a candidate replaces the best only with more parts, so a tie keeps the layout. + int RunBatches(List list, bool earlyExit, bool requireMore) { - for (var batchStart = 0; batchStart < candidates.Count; batchStart += CandidateBatchSize) + for (var batchStart = 0; batchStart < list.Count; batchStart += CandidateBatchSize) { token.ThrowIfCancellationRequested(); - var batchEnd = System.Math.Min(batchStart + CandidateBatchSize, candidates.Count); + var batchEnd = System.Math.Min(batchStart + CandidateBatchSize, list.Count); var results = EvaluateBatch( - candidates, + list, drawing, effectiveWorkArea, batchStart, @@ -215,17 +223,47 @@ namespace OpenNest.Engine.Fill workArea, effectiveWorkArea, targetCount, - candidates.Count, + list.Count, batchStart, - reportProgress + reportProgress, + requireMore ); - if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) + if (earlyExit && batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) { Debug.WriteLine( - $"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates" + $"[PairFiller] Early exit at {batchEnd}/{list.Count} — no improvement in last {sinceImproved} candidates" ); - break; + return batchEnd; + } + } + + return list.Count; + } + + try + { + var stoppedAt = RunBatches(candidates, earlyExit: true, requireMore: false); + + // The candidates are ranked by their grids alone, so the early exit can skip a + // narrower grid whose leftover strip holds more parts than a fuller grid gains: + // a column of gusset pairs slid to leave a strip for gussets turned 45 degrees. + // Those whose grid and remnant estimate could still win are tried anyway. + if (stoppedAt < candidates.Count && best != null + && (targetCount <= 0 || best.Count < targetCount)) + { + var minWidth = RotationAnalysis.MinimumWidth(drawing); + var promising = candidates + .Skip(stoppedAt) + .Where(c => candidateSelector.EstimateWithRemnant( + c, workArea, minWidth, maxUtilization, partArea) > best.Count) + .ToList(); + if (promising.Count > 0) + { + Debug.WriteLine( + $"[PairFiller] Trying {promising.Count} skipped candidates whose remnant could win" + ); + RunBatches(promising, earlyExit: false, requireMore: true); } } } @@ -293,12 +331,15 @@ namespace OpenNest.Engine.Fill int targetCount, int totalCandidates, int batchStart, - Action, string> reportProgress + Action, string> reportProgress, + bool requireMore = false ) { for (var j = 0; j < results.Length; j++) { - if (comparer.IsBetter(results[j], best, effectiveWorkArea)) + var placesMore = !requireMore || best == null + || (results[j]?.Count ?? 0) > best.Count; + if (placesMore && comparer.IsBetter(results[j], best, effectiveWorkArea)) { best = results[j]; sinceImproved = 0; @@ -511,7 +552,7 @@ namespace OpenNest.Engine.Fill /// /// The candidate /// The - private static List BuildTilingAngles(BestFitResult candidate) + internal static List BuildTilingAngles(BestFitResult candidate) { var angles = new List(candidate.HullAngles); var optAngle = -candidate.OptimalRotation; diff --git a/OpenNest.Engine/Fill/PairRemnantFiller.cs b/OpenNest.Engine/Fill/PairRemnantFiller.cs index b5c5bf30..fbcd9c06 100644 --- a/OpenNest.Engine/Fill/PairRemnantFiller.cs +++ b/OpenNest.Engine/Fill/PairRemnantFiller.cs @@ -29,6 +29,15 @@ namespace OpenNest.Engine.Fill private readonly ConcurrentDictionary<(double Width, double Length), List> pairStrips = new(); + /// Pairs tried when tiling a strip with one pair as a grid. + private const int MaxGridCandidates = 50; + + /// Grid candidates tried in a row without improvement before giving up. + private const int GridStaleLimit = 10; + + private readonly ConcurrentDictionary minimumWidths = + new(ReferenceEqualityComparer.Instance); + public PairRemnantFiller(double partSpacing, IFillComparer comparer) { this.partSpacing = partSpacing; @@ -73,6 +82,11 @@ namespace OpenNest.Engine.Fill var partBox = drawing.Program.BoundingBox(); var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing; + // With pairs to tile, a strip narrower than the part's bounding box can still hold + // it turned, as a gusset turned 45 degrees fits beside a column of gusset pairs. + if (bestFits != null && plate != null) + minDim = System.Math.Min(minDim, MinimumWidth(drawing)); + List bestRemnant = null; // Try top remnant (full width, above grid) @@ -214,11 +228,87 @@ namespace OpenNest.Engine.Fill // The stripe filler lays rows along both axes itself; its axis only names it. var best = new StripeFiller(context, NestDirection.Horizontal).Fill(); + // A grid replaces the stripes only when it places more; a tie keeps the stripes. + var grid = TilePairs(drawing, area, bestFits, token); + if (grid != null && grid.Count > (best?.Count ?? 0)) + best = grid; + Debug.WriteLine( $"[PairFiller] Remnant pairs: {best?.Count ?? 0} parts in " + $"{area.Width:F2}x{area.Length:F2}" ); return best; } + + /// + /// The strip tiled with one pair as a grid, as tiles a work area, + /// trying the pairs whose short side fits the strip. Finds layouts the stripe filler's + /// turned rows miss, such as gusset pairs at 45 degrees zigzagging up a narrow strip. + /// + private List TilePairs( + Drawing drawing, + Box area, + List bestFits, + CancellationToken token + ) + { + var shortSide = System.Math.Min(area.Width, area.Length); + var candidates = bestFits + .Where(fit => fit.Keep && fit.ShortestSide <= shortSide + Tolerance.Epsilon) + .OrderByDescending(fit => EstimateTileCount(fit, area)) + .ThenByDescending(fit => fit.Utilization) + .Take(MaxGridCandidates); + + var engine = new FillLinear(area, partSpacing) { Label = "Pairs-Remnant" }; + List best = null; + var sinceImproved = 0; + foreach (var fit in candidates) + { + token.ThrowIfCancellationRequested(); + if (sinceImproved >= GridStaleLimit) + break; + + var improved = false; + var pairParts = fit.BuildParts(drawing); + foreach (var angle in PairFiller.BuildTilingAngles(fit)) + { + var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle); + if (pattern.Parts.Count == 0) + continue; + + foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical }) + { + var parts = engine.Fill(pattern, dir); + if (parts == null || parts.Count == 0 || !comparer.IsBetter(parts, best, area)) + continue; + + best = parts; + improved = true; + } + } + + sinceImproved = improved ? 0 : sinceImproved + 1; + } + + return best; + } + + private int EstimateTileCount(BestFitResult fit, Box area) + { + int Count(double pairW, double pairH) => + pairW <= 0 || pairH <= 0 + ? 0 + : (int)((area.Width + partSpacing) / (pairW + partSpacing)) + * (int)((area.Length + partSpacing) / (pairH + partSpacing)) + * 2; + + return System.Math.Max( + Count(fit.BoundingWidth, fit.BoundingHeight), + Count(fit.BoundingHeight, fit.BoundingWidth) + ); + } + + private double MinimumWidth(Drawing drawing) => + minimumWidths.GetOrAdd(drawing, RotationAnalysis.MinimumWidth); } } diff --git a/OpenNest.Engine/Fill/RotationAnalysis.cs b/OpenNest.Engine/Fill/RotationAnalysis.cs index 4cb24311..cab26610 100644 --- a/OpenNest.Engine/Fill/RotationAnalysis.cs +++ b/OpenNest.Engine/Fill/RotationAnalysis.cs @@ -85,6 +85,46 @@ namespace OpenNest.Engine.Fill return GetHullEdgeAngles(hull); } + /// + /// The narrowest strip the drawing's material fits in at any rotation: the least width of + /// its convex hull, which is found across one of the hull's edges. A right-angle gusset + /// fits a strip narrower than either side of its bounding box when turned 45 degrees. + /// Zero when the drawing has no material outline. + /// + public static double MinimumWidth(Drawing drawing) + { + var shapes = ShapeBuilder.GetShapes( + ConvertProgram.ToGeometry(drawing.Program).ForNesting() + ); + var points = shapes + .SelectMany(s => s.ToPolygonWithTolerance(0.01)?.Vertices ?? new List()) + .ToList(); + if (points.Count < 3) + return 0; + + var vertices = ConvexHull.Compute(points).Vertices; + var minimum = double.MaxValue; + for (var i = 0; i < vertices.Count; i++) + { + var a = vertices[i]; + var b = vertices[(i + 1) % vertices.Count]; + var length = a.DistanceTo(b); + if (length < Tolerance.Epsilon) + continue; + + var width = 0.0; + foreach (var v in vertices) + { + var cross = (b.X - a.X) * (v.Y - a.Y) - (b.Y - a.Y) * (v.X - a.X); + width = System.Math.Max(width, System.Math.Abs(cross) / length); + } + + minimum = System.Math.Min(minimum, width); + } + + return minimum == double.MaxValue ? 0 : minimum; + } + public static List GetHullEdgeAngles(Polygon hull) { var vertices = hull.Vertices; diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index fde5fad9..24536d48 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -115,6 +115,16 @@ included, and keeps whichever places the most; a tie stays staggered. A 9-inch c 60 x 120 sheet at 0.2 spacing fits 89 that way (five staggered rows of 13 and 12, then two straight rows of 13) against 88 staggered throughout. +Lattice's pairs phase also tiles the strip its grid leaves with one best-fit pair as a grid, and +tries a strip narrower than the part's bounding box when the part fits it turned (the least width +of its convex hull); the grid replaces the rows of pairs only when it places more. Its candidates +are ranked by grid count alone and it stops after ten in a row fail to improve, so it then also +tries the skipped candidates whose grid plus an optimistic estimate of their leftover strips could +still win, keeping one only when it places more parts; the estimate counts parts by their true +area, not their bounding box. An 8" gusset on a 16.85 x 60 strip fills 20 that way: six pairs slid +along their hypotenuse in an 8.75" column, and eight gussets at 45 degrees zigzagging up the strip +beside it, against 14 in a 10.35" column of seven pairs. + Lattice's last strategy, TiltedRemnant, adds tilted parts beside the best layout when the room left past it on the right or top is too narrow for the part at 0 or 90 degrees, such as a long part beside a grid of the same part turned 90 degrees. It moves the layout flush to the low end