refactor(circle-fill): merge FillEndOdd and FillEndEven into StaggeredFill

The two stripe layouts differ only in how far odd stripes shift, so they are
now one StaggeredFill with StaggerMode.HalfPitch (was FillEndOdd: half-pitch
shift, odd stripes one disc fewer) and StaggerMode.SlackShift (was
FillEndEven: shift by the stripe's leftover, every stripe full). The unused
FillEngine base and its throwing Fill(item, maxCount) go away. Layouts and
orientation tie-breaks are unchanged.

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 dffb06a9e3
commit b5bc03bea1
6 files changed
+217 -287

No files matched your search

@@ -3,7 +3,7 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.CirclePacking;
public class FillEndOverlapTests
public class StaggeredFillTests
{
private const double Diameter = 10;
@@ -18,11 +18,11 @@ public class FillEndOverlapTests
[Theory]
[MemberData(nameof(BinSizes))]
public void FillEndOdd_PlacesNoOverlappingCircles(double width, double length)
public void HalfPitch_PlacesNoOverlappingCircles(double width, double length)
{
var bin = new Bin { Location = new Vector(0, 0), Size = new Size(width, length) };
new FillEndOdd(bin).Fill(CreateItem());
StaggeredFill.Fill(bin, CreateItem(), StaggerMode.HalfPitch);
Assert.NotEmpty(bin.Items);
AssertNoOverlaps(bin.Items);
@@ -30,11 +30,11 @@ public class FillEndOverlapTests
[Theory]
[MemberData(nameof(BinSizes))]
public void FillEndEven_PlacesNoOverlappingCircles(double width, double length)
public void SlackShift_PlacesNoOverlappingCircles(double width, double length)
{
var bin = new Bin { Location = new Vector(0, 0), Size = new Size(width, length) };
new FillEndEven(bin).Fill(CreateItem());
StaggeredFill.Fill(bin, CreateItem(), StaggerMode.SlackShift);
Assert.NotEmpty(bin.Items);
Assert.All(bin.Items, i => Assert.Equal(Diameter, i.Diameter, 9));
@@ -42,35 +42,35 @@ public class FillEndOverlapTests
}
[Fact]
public void FillEndOdd_SingleCircleWidth_StacksAlongTheOtherAxis()
public void HalfPitch_SingleCircleWidth_StacksAlongTheOtherAxis()
{
var bin = new Bin { Location = new Vector(0, 0), Size = new Size(1.5 * Diameter, 1.5 * Diameter) };
new FillEndOdd(bin).Fill(CreateItem());
StaggeredFill.Fill(bin, CreateItem(), StaggerMode.HalfPitch);
AssertNoOverlaps(bin.Items);
}
[Theory]
[MemberData(nameof(BinSizes))]
public void FillEnd_KeepsEveryCircleInsideTheBin(double width, double length)
public void StaggeredFill_KeepsEveryCircleInsideTheBin(double width, double length)
{
var odd = new Bin { Location = new Vector(3, 7), Size = new Size(width, length) };
var even = new Bin { Location = new Vector(3, 7), Size = new Size(width, length) };
new FillEndOdd(odd).Fill(CreateItem());
new FillEndEven(even).Fill(CreateItem());
StaggeredFill.Fill(odd, CreateItem(), StaggerMode.HalfPitch);
StaggeredFill.Fill(even, CreateItem(), StaggerMode.SlackShift);
AssertInside(odd);
AssertInside(even);
}
[Fact]
public void FillEndOdd_StretchesStripesToTheBinOnALongSheet()
public void HalfPitch_StretchesStripesToTheBinOnALongSheet()
{
var bin = new Bin { Location = new Vector(0, 0), Size = new Size(59.375, 119.75) };
new FillEndOdd(bin).Fill(new Item { Radius = 4.125 });
StaggeredFill.Fill(bin, new Item { Radius = 4.125 }, StaggerMode.HalfPitch);
// Staggered all the way places 108; the last stripes stacked straight fit one more.
AssertNoOverlaps(bin.Items, 8.25);
@@ -79,24 +79,24 @@ public class FillEndOverlapTests
}
[Fact]
public void FillEndEven_TriesTheStripesAlongTheLongSide()
public void SlackShift_TriesTheStripesAlongTheLongSide()
{
var bin = new Bin { Location = new Vector(0, 0), Size = new Size(59.375, 119.75) };
new FillEndEven(bin).Fill(new Item { Radius = 4.125 });
StaggeredFill.Fill(bin, new Item { Radius = 4.125 }, StaggerMode.SlackShift);
AssertNoOverlaps(bin.Items, 8.25);
Assert.Equal(112, bin.Items.Count);
}
[Fact]
public void FillEndOdd_EndsWithStraightStripesWhenTheyFitMore()
public void HalfPitch_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 });
StaggeredFill.Fill(bin, new Item { Radius = 4.6 }, StaggerMode.HalfPitch);
AssertNoOverlaps(bin.Items, 9.2);
AssertInside(bin);
@@ -112,13 +112,13 @@ public class FillEndOverlapTests
}
[Fact]
public void FillEndOdd_KeepsStaggeredStripesWhenStraightOnesFitNoMore()
public void HalfPitch_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 });
StaggeredFill.Fill(bin, new Item { Radius = 4.6 }, StaggerMode.HalfPitch);
Assert.Equal(88, bin.Items.Count);
var stripes = bin.Items.GroupBy(i => System.Math.Round(i.Center.Y, 6)).Select(g => g.Count());
@@ -1,84 +0,0 @@
using System;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CirclePacking
{
internal class FillEndEven : FillEngine
{
public FillEndEven(Bin bin)
: base(bin) { }
public override void Fill(Item item)
{
var bin1 = FillHorizontal(item);
var bin2 = FillVertical(item);
if (bin1.Items.Count > bin2.Items.Count)
Bin.Items.AddRange(bin1.Items);
else
Bin.Items.AddRange(bin2.Items);
}
public override void Fill(Item item, int maxCount)
{
throw new NotImplementedException();
}
private Bin FillHorizontal(Item item) => FillAxis(item, horizontal: true);
private Bin FillVertical(Item item) => FillAxis(item, horizontal: false);
/// <summary>Stripes run along Y and step along X when horizontal, and the reverse otherwise.</summary>
private Bin FillAxis(Item item, bool horizontal)
{
var bin = Bin.Clone() as Bin;
var max = new Vector(
bin.Right - item.Radius + Tolerance.Epsilon,
bin.Top - item.Radius + Tolerance.Epsilon
);
// A box's Length runs along X and its Width along Y.
var stripeSize = horizontal ? bin.Width : bin.Length;
var diameter = item.Diameter;
var radius = item.Radius;
var rows = System.Math.Floor((stripeSize + Tolerance.Epsilon) / diameter);
if (rows == 0)
return bin;
var remaining = stripeSize - diameter * rows;
double oddShift;
double innerOffset;
double outerOffset;
if (remaining < radius)
{
oddShift = remaining;
innerOffset = diameter;
outerOffset = Trigonometry.Base(remaining, diameter);
}
else
{
oddShift = (stripeSize - diameter) / (2 * rows - 1);
innerOffset = oddShift * 2.0;
// Adjacent stripes touch diagonally, but stripes two apart sit directly beside
// each other, so the stripe spacing can never drop below a radius.
outerOffset = System.Math.Max(Trigonometry.Base(oddShift, diameter), radius);
}
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;
}
}
}
@@ -1,72 +0,0 @@
using System;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CirclePacking
{
internal class FillEndOdd : FillEngine
{
public FillEndOdd(Bin bin)
: base(bin) { }
public override void Fill(Item item)
{
var bin1 = FillHorizontal(item);
var bin2 = FillVertical(item);
if (bin1.Items.Count > bin2.Items.Count)
Bin.Items.AddRange(bin1.Items);
else
Bin.Items.AddRange(bin2.Items);
}
public override void Fill(Item item, int maxCount)
{
throw new NotImplementedException();
}
private Bin FillHorizontal(Item item) => FillAxis(item, horizontal: true);
private Bin FillVertical(Item item) => FillAxis(item, horizontal: false);
private Bin FillAxis(Item item, bool horizontal)
{
var bin = Bin.Clone() as Bin;
var max = new Vector(
bin.Right - item.Radius + Tolerance.Epsilon,
bin.Top - item.Radius + Tolerance.Epsilon
);
// A box's Length runs along X and its Width along Y.
var primarySize = horizontal ? bin.Length : bin.Width;
var count = System.Math.Floor((primarySize + Tolerance.Epsilon) / item.Diameter);
if (count == 0)
return bin;
// A single circle per stripe cannot stagger; stack the stripes a diameter apart.
var primaryOffset = count > 1
? (primarySize - item.Diameter) / (count - 1)
: double.PositiveInfinity;
// Adjacent stripes touch diagonally, but stripes two apart sit directly over each
// other, so the stripe spacing can never drop below a radius.
var secondaryOffset = count > 1
? System.Math.Max(Trigonometry.Height(primaryOffset * 0.5, item.Diameter), item.Diameter * 0.5)
: item.Diameter;
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;
}
}
}
-106
View File
@@ -1,106 +0,0 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CirclePacking
{
internal abstract class FillEngine
{
public FillEngine(Bin bin)
{
Bin = bin;
}
public Bin Bin { get; set; }
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
);
}
}
@@ -0,0 +1,191 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CirclePacking
{
/// <summary>How far <see cref="StaggeredFill"/> shifts every other stripe along its length.</summary>
internal enum StaggerMode
{
/// <summary>
/// Half the pitch, so odd stripes nest in the gaps and hold one disc fewer; the pitch is
/// stretched until a stripe's discs span the bin.
/// </summary>
HalfPitch,
/// <summary>
/// By the room a full stripe leaves, so every stripe holds as many discs as fit and odd
/// stripes drop only as deep into the gaps as that shift allows.
/// </summary>
SlackShift,
}
/// <summary>
/// Fills a bin with stripes of equal discs, every other stripe shifted along its length by the
/// <see cref="StaggerMode"/>, stripes along X and along Y tried and the one placing more kept.
/// </summary>
internal static class StaggeredFill
{
public static void Fill(Bin bin, Item item, StaggerMode mode)
{
var alongX = Layout(bin, item, mode, alongX: true);
var alongY = Layout(bin, item, mode, alongX: false);
// On a tie, half-pitch stripes run along Y and slack-shifted ones along X.
var keepAlongX = mode == StaggerMode.HalfPitch
? alongX.Count > alongY.Count
: alongX.Count >= alongY.Count;
bin.Items.AddRange(keepAlongX ? alongX : alongY);
}
/// <summary>Stripes run along X and step along Y when <paramref name="alongX"/>, and the reverse otherwise.</summary>
private static List<Item> Layout(Bin bin, Item item, StaggerMode mode, bool alongX)
{
var items = new List<Item>();
var diameter = item.Diameter;
var radius = item.Radius;
// A box's Length runs along X and its Width along Y.
var stripeLength = alongX ? bin.Length : bin.Width;
var count = System.Math.Floor((stripeLength + Tolerance.Epsilon) / diameter);
if (count == 0)
return items;
var (pitch, step, shift) = mode == StaggerMode.HalfPitch
? HalfPitch(stripeLength, count, diameter)
: SlackShift(stripeLength, count, diameter);
var max = new Vector(
bin.Right - radius + Tolerance.Epsilon,
bin.Top - radius + Tolerance.Epsilon
);
AddStripes(items, item, new Stripes(
AlongX: alongX,
OuterStart: (alongX ? bin.Y : bin.X) + radius,
OuterMax: alongX ? max.Y : max.X,
OuterStep: step,
InnerStart: (alongX ? bin.X : bin.Y) + radius,
InnerMax: alongX ? max.X : max.Y,
InnerStep: pitch,
Shift: shift));
return items;
}
private static (double Pitch, double Step, double Shift) HalfPitch(double stripeLength, double count, double diameter)
{
// A single disc per stripe cannot stagger; stack the stripes a diameter apart.
if (count == 1)
return (double.PositiveInfinity, diameter, 0);
var pitch = (stripeLength - diameter) / (count - 1);
// Adjacent stripes touch diagonally, but stripes two apart sit directly over each
// other, so the stripe spacing can never drop below a radius.
return (pitch, System.Math.Max(Trigonometry.Height(pitch * 0.5, diameter), diameter * 0.5), pitch * 0.5);
}
private static (double Pitch, double Step, double Shift) SlackShift(double stripeLength, double count, double diameter)
{
var remaining = stripeLength - diameter * count;
if (remaining < diameter * 0.5)
return (diameter, Trigonometry.Base(remaining, diameter), remaining);
var shift = (stripeLength - diameter) / (2 * count - 1);
// Adjacent stripes touch diagonally, but stripes two apart sit directly beside
// each other, so the stripe spacing can never drop below a radius.
return (shift * 2.0, System.Math.Max(Trigonometry.Base(shift, diameter), diameter * 0.5), shift);
}
/// <summary>
/// Lays staggered stripes <see cref="Stripes.OuterStep"/> apart, 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 bin 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>
private static void AddStripes(List<Item> items, 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(items, 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(items, 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(List<Item> items, 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.AlongX
? new Vector(inner, outer)
: new Vector(outer, inner);
items.Add(addedItem);
}
}
/// <summary>
/// Stripes run along the inner axis, X when <see cref="AlongX"/>, and step along the
/// other; starts and maxima are disc centers.
/// </summary>
private readonly record struct Stripes(
bool AlongX,
double OuterStart,
double OuterMax,
double OuterStep,
double InnerStart,
double InnerMax,
double InnerStep,
double Shift
);
}
}
@@ -9,8 +9,9 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.Strategies;
/// <summary>
/// Staggers round parts edge to edge with <see cref="FillEndOdd"/> and <see cref="FillEndEven"/>,
/// which stretch a row's pitch to the sheet so the next row drops deeper than a fixed lattice does.
/// Staggers round parts edge to edge with <see cref="StaggeredFill"/>, whose half-pitch rows stretch
/// their pitch to the sheet so the next row drops deeper than a fixed lattice does, and whose
/// slack-shifted rows keep every row full; the better of the two is kept.
/// </summary>
public class CircleFillStrategy : IFillStrategy
{
@@ -35,22 +36,22 @@ public class CircleFillStrategy : IFillStrategy
var area = context.WorkArea.Offset(gap * 0.5);
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
var odd = Pack(area, spaced, bin => new FillEndOdd(bin), item, context.Item.Drawing);
var even = Pack(area, spaced, bin => new FillEndEven(bin), item, context.Item.Drawing);
var halfPitch = Pack(area, spaced, StaggerMode.HalfPitch, item, context.Item.Drawing);
var slackShift = Pack(area, spaced, StaggerMode.SlackShift, item, context.Item.Drawing);
return comparer.IsBetter(even, odd, context.WorkArea) ? even : odd;
return comparer.IsBetter(slackShift, halfPitch, context.WorkArea) ? slackShift : halfPitch;
}
private static List<Part> Pack(
Box area,
Item spaced,
System.Func<Bin, FillEngine> engine,
StaggerMode mode,
Item item,
Drawing drawing
)
{
var bin = new Bin { Location = area.Location, Size = area.Size };
engine(bin).Fill(spaced);
StaggeredFill.Fill(bin, spaced, mode);
return bin.Items.Select(i => new Part(drawing, i.Center + item.PartOffset)).ToList();
}
}