diff --git a/OpenNest.Engine/Strategies/CircleFillStrategy.cs b/OpenNest.Engine/Strategies/CircleFillStrategy.cs
new file mode 100644
index 00000000..94b98c2e
--- /dev/null
+++ b/OpenNest.Engine/Strategies/CircleFillStrategy.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Engine.CirclePacking;
+using OpenNest.Engine.Fill;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Cutouts;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Strategies;
+
+///
+/// Staggers round parts edge to edge with and ,
+/// which stretch a row's pitch to the sheet so the next row drops deeper than a fixed lattice does.
+///
+public class CircleFillStrategy : IFillStrategy
+{
+ public string Name => "Circle";
+ public NestPhase Phase => NestPhase.Custom;
+ public int Order => 170;
+
+ public List Fill(FillContext context)
+ {
+ if (context.PartType != PartType.Circle)
+ return null;
+
+ var item = Item.TryCreate(PartGeometrySnapshot.FromProgram(context.Item.Drawing.Program));
+ if (item == null)
+ return null;
+
+ // Half the gap on every disc, and the work area grown to match, so discs keep the spacing
+ // from each other and only their own radius from the work area's edges.
+ var gap = context.Plate.PartSpacing + CutoutCirclePacker.Margin;
+ var spaced = item.Clone();
+ spaced.Radius += gap * 0.5;
+ 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);
+
+ return comparer.IsBetter(even, odd, context.WorkArea) ? even : odd;
+ }
+
+ private static List Pack(
+ Box area,
+ Item spaced,
+ System.Func engine,
+ Item item,
+ Drawing drawing
+ )
+ {
+ var bin = new Bin { Location = area.Location, Size = area.Size };
+ engine(bin).Fill(spaced);
+ return bin.Items.Select(i => new Part(drawing, i.Center + item.PartOffset)).ToList();
+ }
+}
diff --git a/OpenNest.Engine/Strategies/FillStrategyRegistry.cs b/OpenNest.Engine/Strategies/FillStrategyRegistry.cs
index 30b5b4d6..a670eea2 100644
--- a/OpenNest.Engine/Strategies/FillStrategyRegistry.cs
+++ b/OpenNest.Engine/Strategies/FillStrategyRegistry.cs
@@ -17,6 +17,7 @@ namespace OpenNest.Engine.Strategies
new PairsFillStrategy(),
new RowFillStrategy(),
new ColumnFillStrategy(),
+ new CircleFillStrategy(),
new RectBestFitStrategy(),
new ExtentsFillStrategy(),
new LinearFillStrategy(),
diff --git a/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs b/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs
new file mode 100644
index 00000000..1c795e77
--- /dev/null
+++ b/OpenNest.Tests/Strategies/CircleFillStrategyTests.cs
@@ -0,0 +1,99 @@
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.Engine;
+using OpenNest.Engine.Fill;
+using OpenNest.Engine.Jobs.Placement.Fillers;
+using OpenNest.Engine.Strategies;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Tests.Strategies;
+
+public class CircleFillStrategyTests
+{
+ private const double Radius = 4;
+ private const double Gap = 0.25;
+
+ private static Drawing MakeCircleDrawing()
+ {
+ var pgm = new Program();
+ var start = new Vector(Radius, 0);
+ pgm.Codes.Add(new RapidMove(start));
+ pgm.Codes.Add(new ArcMove(start, new Vector(0, 0), RotationType.CCW));
+ return new Drawing("circle", pgm);
+ }
+
+ private static Plate MakePlate()
+ {
+ var plate = new Plate(60, 120) { PartSpacing = Gap };
+ plate.EdgeSpacing = new Spacing(Gap, Gap, Gap, Gap);
+ return plate;
+ }
+
+ [Fact]
+ public void Fill_EightInchCirclesOnASixtyByOneTwenty_BeatsTheStripeLayout()
+ {
+ var plate = MakePlate();
+ var filler = new PlateFiller(plate);
+ var item = new NestItem { Drawing = MakeCircleDrawing() };
+
+ var parts = filler.Fill(item, plate.WorkArea(), null, CancellationToken.None);
+
+ Assert.Equal(112, parts.Count);
+ Assert.Equal(NestPhase.Custom, filler.WinnerPhase);
+ AssertSpacedInside(parts, plate.WorkArea());
+ }
+
+ [Fact]
+ public void Fill_RoundPartOffItsOrigin_PlacesItAtTheFilledCenter()
+ {
+ var pgm = new Program();
+ var start = new Vector(25, 10);
+ pgm.Codes.Add(new RapidMove(start));
+ pgm.Codes.Add(new ArcMove(start, new Vector(21, 10), RotationType.CCW));
+ var plate = MakePlate();
+ var filler = new PlateFiller(plate);
+
+ var parts = filler.Fill(
+ new NestItem { Drawing = new Drawing("offset", pgm) },
+ plate.WorkArea(),
+ null,
+ CancellationToken.None
+ );
+
+ Assert.Equal(112, parts.Count);
+ var work = plate.WorkArea();
+ Assert.All(parts, p => Assert.True(work.Contains(p.BoundingBox.Offset(-Tolerance.Epsilon))));
+ }
+
+ [Fact]
+ public void Strategy_SkipsPartsThatAreNotRound()
+ {
+ var plate = MakePlate();
+ var context = new FillContext
+ {
+ Item = new NestItem { Drawing = TestHelpers.MakeRectDrawing(20, 10) },
+ WorkArea = plate.WorkArea(),
+ Plate = plate,
+ PartType = PartType.Rectangle,
+ };
+
+ Assert.Null(new CircleFillStrategy().Fill(context));
+ }
+
+ private static void AssertSpacedInside(System.Collections.Generic.List parts, Box work)
+ {
+ var centers = parts.Select(p => p.BoundingBox.Center).ToList();
+ for (var i = 0; i < centers.Count; i++)
+ {
+ Assert.True(centers[i].X - Radius >= work.Left - Tolerance.Epsilon);
+ Assert.True(centers[i].X + Radius <= work.Right + Tolerance.Epsilon);
+ Assert.True(centers[i].Y - Radius >= work.Bottom - Tolerance.Epsilon);
+ Assert.True(centers[i].Y + Radius <= work.Top + Tolerance.Epsilon);
+
+ for (var j = i + 1; j < centers.Count; j++)
+ Assert.True(centers[i].DistanceTo(centers[j]) >= 2 * Radius + Gap - Tolerance.Epsilon);
+ }
+ }
+}
diff --git a/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs b/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
index 6f98c54e..2e7f52e7 100644
--- a/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
+++ b/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
@@ -9,11 +9,11 @@ public class FillStrategyRegistryTests
public void Registry_ListsBuiltInStrategiesInPipelineOrder()
{
Assert.Equal(
- ["Pairs", "Row", "Column", "RectBestFit", "Extents", "Linear", "TiltedRemnant"],
+ ["Pairs", "Row", "Column", "Circle", "RectBestFit", "Extents", "Linear", "TiltedRemnant"],
FillStrategyRegistry.AllStrategies.Select(s => s.Name)
);
Assert.Equal(
- [100, 150, 160, 200, 300, 400, 500],
+ [100, 150, 160, 170, 200, 300, 400, 500],
FillStrategyRegistry.AllStrategies.Select(s => s.Order)
);
}