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