From dffb06a9e33494fb8f4391664c16f6350d98a2d6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Nov 2026 21:53:38 -0500 Subject: [PATCH] feat(circle-fill): end staggered rows with straight rows when that fits more The Circle fill strategy's stripe layouts (FillEndOdd, FillEndEven) now try switching from staggered to straight stripes, a diameter apart over the last staggered stripe, at every stripe, all staggered and all straight included, and keep the switch that places the most; a tie stays staggered. A 9" circle on a 60x120 sheet at 0.2 spacing now fills 89 (five staggered rows, two straight) instead of 88. Co-Authored-By: Claude Opus 5.5 --- .../CirclePacking/FillEndOverlapTests.cs | 40 +++++++- OpenNest.Engine/CirclePacking/FillEndEven.cs | 31 ++----- OpenNest.Engine/CirclePacking/FillEndOdd.cs | 31 ++----- OpenNest.Engine/CirclePacking/FillEngine.cs | 92 ++++++++++++++++++- .../Strategies/CircleFillStrategyTests.cs | 34 +++++++ docs/nesting-engines.md | 8 ++ 6 files changed, 190 insertions(+), 46 deletions(-) diff --git a/OpenNest.Engine.Tests/CirclePacking/FillEndOverlapTests.cs b/OpenNest.Engine.Tests/CirclePacking/FillEndOverlapTests.cs index 3e126adb..d9edde63 100644 --- a/OpenNest.Engine.Tests/CirclePacking/FillEndOverlapTests.cs +++ b/OpenNest.Engine.Tests/CirclePacking/FillEndOverlapTests.cs @@ -72,8 +72,10 @@ public class FillEndOverlapTests new FillEndOdd(bin).Fill(new Item { Radius = 4.125 }); + // Staggered all the way places 108; the last stripes stacked straight fit one more. AssertNoOverlaps(bin.Items, 8.25); - Assert.Equal(108, bin.Items.Count); + AssertInside(bin); + Assert.Equal(109, bin.Items.Count); } [Fact] @@ -87,6 +89,42 @@ public class FillEndOverlapTests Assert.Equal(112, bin.Items.Count); } + [Fact] + public void FillEndOdd_EndsWithStraightStripesWhenTheyFitMore() + { + // 13 discs to a stripe: seven staggered stripes place 88 and six straight ones 78, but + // five staggered and two straight over the fifth place 89. + var bin = new Bin { Location = new Vector(0, 0), Size = new Size(59.8, 119.8) }; + + new FillEndOdd(bin).Fill(new Item { Radius = 4.6 }); + + AssertNoOverlaps(bin.Items, 9.2); + AssertInside(bin); + Assert.Equal(89, bin.Items.Count); + var stripes = bin.Items.GroupBy(i => System.Math.Round(i.Center.Y, 6)).OrderBy(g => g.Key).ToList(); + Assert.Equal(new[] { 13, 12, 13, 12, 13, 13, 13 }, stripes.Select(s => s.Count())); + var fifth = stripes[4].Select(i => i.Center.X).Order().ToList(); + for (var stripe = 5; stripe < stripes.Count; stripe++) + { + Assert.Equal(9.2, stripes[stripe].Key - stripes[stripe - 1].Key, 6); + Assert.Equal(fifth, stripes[stripe].Select(i => i.Center.X).Order()); + } + } + + [Fact] + public void FillEndOdd_KeepsStaggeredStripesWhenStraightOnesFitNoMore() + { + // Seven staggered stripes place 88; too short for the mixed 89, straight stripes from + // any switch point place at most 78. + var bin = new Bin { Location = new Vector(0, 0), Size = new Size(57.2, 119.8) }; + + new FillEndOdd(bin).Fill(new Item { Radius = 4.6 }); + + Assert.Equal(88, bin.Items.Count); + var stripes = bin.Items.GroupBy(i => System.Math.Round(i.Center.Y, 6)).Select(g => g.Count()); + Assert.Equal(new[] { 13, 12, 13, 12, 13, 12, 13 }, stripes); + } + private static Item CreateItem() => new Item { Radius = Diameter * 0.5, Center = new Vector(Diameter * 0.5, Diameter * 0.5) }; private static void AssertInside(Bin bin) diff --git a/OpenNest.Engine/CirclePacking/FillEndEven.cs b/OpenNest.Engine/CirclePacking/FillEndEven.cs index 4987b7df..3a7d87f1 100644 --- a/OpenNest.Engine/CirclePacking/FillEndEven.cs +++ b/OpenNest.Engine/CirclePacking/FillEndEven.cs @@ -68,28 +68,15 @@ namespace OpenNest.Engine.CirclePacking outerOffset = System.Math.Max(Trigonometry.Base(oddShift, diameter), radius); } - var outerStart = (horizontal ? bin.X : bin.Y) + radius; - var outerMax = horizontal ? max.X : max.Y; - var innerStart = (horizontal ? bin.Y : bin.X) + radius; - var innerMax = horizontal ? max.Y : max.X; - - var stripe = 0; - - for (var outer = outerStart; outer <= outerMax; outer += outerOffset) - { - var inner = stripe.IsOdd() ? innerStart + oddShift : innerStart; - - for (; inner <= innerMax; inner += innerOffset) - { - var addedItem = item.Clone(); - addedItem.Center = horizontal - ? new Vector(outer, inner) - : new Vector(inner, outer); - bin.Items.Add(addedItem); - } - - stripe++; - } + AddStripes(bin, item, new Stripes( + InnerAlongX: !horizontal, + OuterStart: (horizontal ? bin.X : bin.Y) + radius, + OuterMax: horizontal ? max.X : max.Y, + OuterStep: outerOffset, + InnerStart: (horizontal ? bin.Y : bin.X) + radius, + InnerMax: horizontal ? max.Y : max.X, + InnerStep: innerOffset, + Shift: oddShift)); return bin; } diff --git a/OpenNest.Engine/CirclePacking/FillEndOdd.cs b/OpenNest.Engine/CirclePacking/FillEndOdd.cs index 65289ccb..8183f06c 100644 --- a/OpenNest.Engine/CirclePacking/FillEndOdd.cs +++ b/OpenNest.Engine/CirclePacking/FillEndOdd.cs @@ -56,28 +56,15 @@ namespace OpenNest.Engine.CirclePacking ? System.Math.Max(Trigonometry.Height(primaryOffset * 0.5, item.Diameter), item.Diameter * 0.5) : item.Diameter; - var outerStart = (horizontal ? bin.Y : bin.X) + item.Radius; - var outerMax = horizontal ? max.Y : max.X; - var innerStart = (horizontal ? bin.X : bin.Y) + item.Radius; - var innerMax = horizontal ? max.X : max.Y; - - var stripe = 0; - - for (var outer = outerStart; outer <= outerMax; outer += secondaryOffset) - { - var inner = count > 1 && stripe.IsOdd() ? innerStart + primaryOffset * 0.5 : innerStart; - - for (; inner <= innerMax; inner += primaryOffset) - { - var addedItem = item.Clone(); - addedItem.Center = horizontal - ? new Vector(inner, outer) - : new Vector(outer, inner); - bin.Items.Add(addedItem); - } - - stripe++; - } + AddStripes(bin, item, new Stripes( + InnerAlongX: horizontal, + OuterStart: (horizontal ? bin.Y : bin.X) + item.Radius, + OuterMax: horizontal ? max.Y : max.X, + OuterStep: secondaryOffset, + InnerStart: (horizontal ? bin.X : bin.Y) + item.Radius, + InnerMax: horizontal ? max.X : max.Y, + InnerStep: primaryOffset, + Shift: count > 1 ? primaryOffset * 0.5 : 0)); return bin; } diff --git a/OpenNest.Engine/CirclePacking/FillEngine.cs b/OpenNest.Engine/CirclePacking/FillEngine.cs index 530c72b6..2ffb9259 100644 --- a/OpenNest.Engine/CirclePacking/FillEngine.cs +++ b/OpenNest.Engine/CirclePacking/FillEngine.cs @@ -1,4 +1,8 @@ -namespace OpenNest.Engine.CirclePacking +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.CirclePacking { internal abstract class FillEngine { @@ -12,5 +16,91 @@ public abstract void Fill(Item item); public abstract void Fill(Item item, int maxCount); + + /// + /// Lays staggered stripes apart into , + /// odd stripes shifted along their length by . The last stripes + /// may instead stack straight: a diameter apart, each repeating the last staggered stripe's + /// positions. Straight stacking climbs faster per stripe than staggering, so where the + /// sheet leaves too little for one more staggered stripe, a few straight ones can fit more. + /// Every switch point is tried, all staggered and all straight included, and the one placing + /// the most is kept; a tie keeps the stripes staggered. + /// + protected static void AddStripes(Bin bin, Item item, Stripes stripes) + { + var staggered = new List(); + for (var outer = stripes.OuterStart; outer <= stripes.OuterMax; outer += stripes.OuterStep) + staggered.Add(outer); + + var counts = new int[staggered.Count]; + var total = 0; + for (var stripe = 0; stripe < counts.Length; stripe++) + { + for (var inner = InnerStart(stripes, stripe); inner <= stripes.InnerMax; inner += stripes.InnerStep) + counts[stripe]++; + total += counts[stripe]; + } + + var switchAt = staggered.Count; + var straightCount = 0; + var best = total; + var placed = 0; + + for (var stripe = 0; stripe < counts.Length; stripe++) + { + placed += counts[stripe]; + var straight = 0; + for (var outer = staggered[stripe] + item.Diameter; outer <= stripes.OuterMax; outer += item.Diameter) + straight++; + + if (placed + straight * counts[stripe] > best) + { + best = placed + straight * counts[stripe]; + switchAt = stripe + 1; + straightCount = straight; + } + } + + for (var stripe = 0; stripe < switchAt; stripe++) + AddStripe(bin, item, stripes, staggered[stripe], InnerStart(stripes, stripe)); + + var last = switchAt - 1; + var straightOuter = last >= 0 ? staggered[last] : 0; + for (var stripe = 0; stripe < straightCount; stripe++) + { + straightOuter += item.Diameter; + AddStripe(bin, item, stripes, straightOuter, InnerStart(stripes, last)); + } + } + + private static double InnerStart(Stripes stripes, int stripe) => + stripe.IsOdd() ? stripes.InnerStart + stripes.Shift : stripes.InnerStart; + + private static void AddStripe(Bin bin, Item item, Stripes stripes, double outer, double innerStart) + { + for (var inner = innerStart; inner <= stripes.InnerMax; inner += stripes.InnerStep) + { + var addedItem = item.Clone(); + addedItem.Center = stripes.InnerAlongX + ? new Vector(inner, outer) + : new Vector(outer, inner); + bin.Items.Add(addedItem); + } + } + + /// + /// Stripes run along the inner axis, X when , and step along the + /// other; starts and maxima are circle centers. + /// + protected readonly record struct Stripes( + bool InnerAlongX, + double OuterStart, + double OuterMax, + double OuterStep, + double InnerStart, + double InnerMax, + double InnerStep, + double Shift + ); } } diff --git a/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs b/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs index 1c795e77..d7961fe8 100644 --- a/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs +++ b/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs @@ -45,6 +45,40 @@ public class CircleFillStrategyTests AssertSpacedInside(parts, plate.WorkArea()); } + [Fact] + public void Fill_NineInchCirclesOnASixtyByOneTwenty_EndsWithStraightRows() + { + // Seven staggered rows of 13 and 12 place 88; five staggered rows and two straight rows + // of 13 over the fifth place 89. + var pgm = new Program(); + var start = new Vector(4.5, 0); + pgm.Codes.Add(new RapidMove(start)); + pgm.Codes.Add(new ArcMove(start, new Vector(0, 0), RotationType.CCW)); + var plate = new Plate(60, 120) { PartSpacing = 0.2 }; + plate.EdgeSpacing = new Spacing(0.2, 0.2, 0.2, 0.2); + var filler = new PlateFiller(plate); + + var parts = filler.Fill( + new NestItem { Drawing = new Drawing("nine", pgm) }, + plate.WorkArea(), + null, + CancellationToken.None + ); + + Assert.Equal(89, parts.Count); + Assert.Equal(NestPhase.Custom, filler.WinnerPhase); + var rows = parts.GroupBy(p => System.Math.Round(p.BoundingBox.Center.Y, 6)).OrderBy(g => g.Key); + Assert.Equal(new[] { 13, 12, 13, 12, 13, 13, 13 }, rows.Select(r => r.Count())); + var work = plate.WorkArea(); + Assert.All(parts, p => Assert.True(work.Contains(p.BoundingBox.Offset(-Tolerance.Epsilon)))); + var centers = parts.Select(p => p.BoundingBox.Center).ToList(); + for (var i = 0; i < centers.Count; i++) + { + for (var j = i + 1; j < centers.Count; j++) + Assert.True(centers[i].DistanceTo(centers[j]) >= 9.2 - Tolerance.Epsilon); + } + } + [Fact] public void Fill_RoundPartOffItsOrigin_PlacesItAtTheFilledCenter() { diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 936ec33f..d43ca96d 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -107,6 +107,14 @@ detail reads "Computing best-fit pairs (may take a minute)...". Accepting or sto waiting for the pair computation, which finishes in the background and is cached for the next fill. +For round parts, Fill's Circle strategy staggers rows edge to edge, stretching each row's pitch +to the sheet. Straight rows, a diameter apart over the last staggered row, climb further per row +but fit more where the sheet leaves too little room for another staggered row, so it tries +switching from staggered to straight rows at every row, all staggered and all straight +included, and keeps whichever places the most; a tie stays staggered. A 9-inch circle on a +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. + Fill'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