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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-04 22:02:03 -05:00
1 parent 8700ae187a
commit dffb06a9e3
6 files changed
+190 -46

No files matched your search

@@ -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)
+9 -22
View File
@@ -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;
}
+9 -22
View File
@@ -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;
}
+91 -1
View File
@@ -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);
/// <summary>
/// Lays staggered stripes <see cref="Stripes.OuterStep"/> apart into <paramref name="bin"/>,
/// odd stripes shifted along their length by <see cref="Stripes.Shift"/>. 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.
/// </summary>
protected static void AddStripes(Bin bin, Item item, Stripes stripes)
{
var staggered = new List<double>();
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);
}
}
/// <summary>
/// Stripes run along the inner axis, X when <see cref="InnerAlongX"/>, and step along the
/// other; starts and maxima are circle centers.
/// </summary>
protected readonly record struct Stripes(
bool InnerAlongX,
double OuterStart,
double OuterMax,
double OuterStep,
double InnerStart,
double InnerMax,
double InnerStep,
double Shift
);
}
}
@@ -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()
{
+8
View File
@@ -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