diff --git a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
index 52da257c..44ef56b1 100644
--- a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
@@ -1,4 +1,5 @@
using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
@@ -12,6 +13,7 @@ public class NestingEngineRegistryTests
Assert.Contains("Rectangles", names);
Assert.Contains("Irregular", names);
+ Assert.Contains("Circles", names);
Assert.Contains("Fill", names);
Assert.Contains("Strip", names);
Assert.Contains("Vertical Remnant", names);
@@ -55,7 +57,13 @@ public class NestingEngineRegistryTests
foreach (var info in NestingEngineRegistry.AvailableEngines)
{
var engine = info.Factory();
- var result = engine.Solve(FiniteStockJobTests.Job(1));
+ // Circles bids only on round parts.
+ var job = info.Name == "Circles"
+ ? new NestJob(new[] { new NestJobPart("p",
+ PartGeometrySnapshot.FromProgram(NestingEngines.Shapes.Disc(5)), 3) },
+ new[] { new NestPlateStock("s", new Size(100, 200), 1) })
+ : FiniteStockJobTests.Job(1);
+ var result = engine.Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
diff --git a/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs
new file mode 100644
index 00000000..f3377cd2
--- /dev/null
+++ b/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.NestingEngines.Circles;
+using OpenNest.Geometry;
+using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
+using static OpenNest.Engine.Tests.NestingEngines.Shapes;
+
+namespace OpenNest.Engine.Tests.NestingEngines;
+
+/// Disc packing of round parts. Every layout is checked by the shared layout check.
+public class CirclesNestingEngineTests
+{
+ [Fact]
+ public void NonRoundJobBidsNothing()
+ {
+ var job = Job([Part("disc", Disc(2), 3), Part("square", Rectangle(4, 4), 3)], [Stock("s", 20, 20)]);
+ var progress = 0;
+ var result = new CirclesNestingEngine().Solve(job, new Capture(_ => progress++));
+
+ Assert.Empty(result.Plates);
+ Assert.Equal(0, progress);
+ Assert.All(result.Fulfillment, f => Assert.Equal(f.Requested, f.Unplaced));
+ Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
+ }
+
+ [Fact]
+ public void EqualDiscsPackHexagonallyDenserThanASquareGrid()
+ {
+ // A square grid holds 100 unit-radius discs on 20.1 x 20.1; staggered rows hold 105.
+ var job = Job([Part("disc", Disc(1), 200)], [Stock("s", 20.1, 20.1, quantity: 1)]);
+ var result = new CirclesNestingEngine().Solve(job);
+
+ LayoutAssert.Valid(job, result);
+ Assert.True(Assert.Single(result.Plates).Placements.Count >= 105,
+ $"{result.Plates[0].Placements.Count} discs");
+ }
+
+ [Fact]
+ public void SmallDiscsFillTheGapsBetweenLargeOnes()
+ {
+ // Four r=5 discs fit a 21 x 21 sheet only as a square; r=1 discs fit only in the five
+ // gaps between them and the edges.
+ var job = Job([Part("large", Disc(5), 4), Part("small", Disc(1), 5)],
+ [Stock("s", 21, 21, spacing: 0.1, quantity: 1)]);
+ var result = new CirclesNestingEngine().Solve(job);
+
+ LayoutAssert.Valid(job, result);
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
+ }
+
+ [Fact]
+ public void ClampZonesAndEdgeClearanceAreKept()
+ {
+ var clamps = new[] { new ClampZone("a", new Box(4, 0, 2, 1)), new ClampZone("b", new Box(14, 0, 2, 1)) };
+ var job = Job([Part("disc", Disc(1.5), 60)],
+ [Stock("s", 20, 20, spacing: 0.25, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quantity: 1, clampZones: clamps)]);
+ var result = new CirclesNestingEngine().Solve(job);
+
+ LayoutAssert.Valid(job, result);
+ Assert.NotEmpty(Assert.Single(result.Plates).Placements);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ public void RandomMixedDiametersNeverOverlap(int seed)
+ {
+ var random = new Random(seed);
+ var parts = Enumerable.Range(0, 6)
+ .Select(i => Part($"p{i}", random.Next(2) == 0
+ ? Disc(0.5 + random.NextDouble() * 4)
+ : Ring(2 + random.NextDouble() * 8, 1),
+ 1 + random.Next(15), RotationPolicy.Automatic))
+ .ToArray();
+ var job = Job(parts, [Stock("a", 24, 36, spacing: 0.2 * random.Next(3)), Stock("b", 30, 30, spacing: 0.2)]);
+ var result = new CirclesNestingEngine().Solve(job);
+
+ LayoutAssert.Valid(job, result);
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ foreach (var plate in result.Plates)
+ {
+ var centers = plate.Placements.Select(p => new Vector(p.X, p.Y)).ToArray();
+ for (var i = 0; i < centers.Length; i++)
+ for (var j = i + 1; j < centers.Length; j++)
+ Assert.NotEqual(centers[i], centers[j]);
+ }
+ }
+
+ [Fact]
+ public void GearsPackByTheirTipCircle()
+ {
+ var job = Job([Part("gear", Gear(12, 4, 3.6), 10, RotationPolicy.Fixed(0.3))], [Stock("s", 30, 30, spacing: 0.25)]);
+ var result = new CirclesNestingEngine().Solve(job);
+
+ LayoutAssert.Valid(job, result);
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0.3, p.Rotation, 12));
+ }
+
+ /// A polygonal gear: alternating tip and root radii.
+ private static Program Gear(int teeth, double tip, double root) =>
+ Polyline(Enumerable.Range(0, 2 * teeth).Select(i =>
+ {
+ var angle = System.Math.PI * i / teeth;
+ var r = i % 2 == 0 ? tip : root;
+ return (r * System.Math.Cos(angle), r * System.Math.Sin(angle));
+ }).ToArray());
+
+ private sealed class Capture(Action action) : IProgress
+ { public void Report(NestJobProgress value) => action(value); }
+}
+
+public sealed class CirclesContractTests : EngineContractTests
+{
+ protected override Program Block(double size) => Disc(size / 2);
+
+ protected override Program Mixed(int which) => which switch
+ {
+ 0 => Ring(9, 4),
+ 1 => Disc(3.5),
+ _ => Ring(6, 3),
+ };
+
+ protected override Program Etchable() => Disc(5);
+}
diff --git a/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs
index 5f3bdb66..d08933fe 100644
--- a/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs
+++ b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs
@@ -10,6 +10,21 @@ namespace OpenNest.Engine.Tests.NestingEngines;
/// Host contract only; engines retain their own packing-quality regressions.
public abstract class EngineContractTests where TEngine : INestingEngine, new()
{
+ /// A part about across; an engine limited to one shape class
+ /// overrides the shapes below with ones it bids on.
+ protected virtual Program Block(double size) => Rectangle(size, size);
+
+ /// Three distinct shapes of decreasing size for mixed jobs.
+ protected virtual Program Mixed(int which) => which switch
+ {
+ 0 => LShape(9, 7, 3),
+ 1 => Triangle(7, 7),
+ _ => LShape(6, 5, 2),
+ };
+
+ /// A part that fits a 10.4 square sheet unrotated; scribe marks are added to it.
+ protected virtual Program Etchable() => NotchedPartWithEtch();
+
[Fact]
public void ContractPublicConstructor() => Assert.IsAssignableFrom(Activator.CreateInstance(typeof(TEngine)));
@@ -20,7 +35,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[InlineData(4)]
public void ContractQuadrants(int quadrant)
{
- var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
+ var job = Job([Part("disc", Disc(2), 3), Part("ell", Mixed(2), 3)],
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
@@ -30,7 +45,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[Fact]
public void ContractOverflowIndicesAndProgress()
{
- var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
+ var job = Job([Part("p", Block(8), 3)], [Stock("s", 10, 10)]);
var commits = new List();
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
LayoutAssert.Valid(job, result);
@@ -47,7 +62,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[Fact]
public void ContractOversize()
{
- var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
+ var job = Job([Part("huge", Block(50), 1), Part("small", Block(2), 2)], [Stock("s", 10, 10)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
@@ -58,7 +73,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[Fact]
public void ContractLowerNumberPriorityWins()
{
- var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
+ var job = Job([Part("low", Block(8), 1, priority: 9), Part("high", Block(8), 1, priority: 0)],
[Stock("s", 10, 10, quantity: 1)]);
var result = new TEngine().Solve(job);
LayoutAssert.Valid(job, result);
@@ -68,7 +83,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[Fact]
public void ContractEtchOutsideSheetIsIgnored()
{
- var etched = NotchedPartWithEtch();
+ var etched = Etchable();
etched.Codes.Add(new RapidMove(5, 5));
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
@@ -80,7 +95,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[Fact]
public void ContractDeterminism()
{
- NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
+ NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", Mixed(0), 12), Part("tri", Mixed(1), 12)],
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
var engine = new TEngine();
var job = Build();
@@ -97,7 +112,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
- var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
+ var job = Job([Part("p", Block(2), 5)], [Stock("s", 10, 10)]);
Assert.ThrowsAny(() => new TEngine().Solve(job, token: cancellation.Token));
}
@@ -105,7 +120,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
public void ContractCancellationDuringSolveThrows()
{
using var cancellation = new CancellationTokenSource();
- var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
+ var job = Job([Part("p", Block(2), 20)], [Stock("s", 10, 10)]);
var progress = new Capture(p =>
{
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
@@ -118,7 +133,7 @@ public abstract class EngineContractTests where TEngine : INestingEngin
[InlineData(false)]
public void ContractStockAndPlateLimits(bool plateLimit)
{
- var job = Job([Part("p", Rectangle(8, 8), 3)],
+ var job = Job([Part("p", Block(8), 3)],
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
var result = new TEngine().Solve(job);
diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
index 73ab8ef7..6377429f 100644
--- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
+++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
+using OpenNest.Engine.NestingEngines.Circles;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.FlatBar;
using OpenNest.Engine.NestingEngines.Irregular;
@@ -38,7 +39,7 @@ public static class NestingEngineRegistry
// Listed first: the engine front ends use when the caller names none.
Register(
"Default",
- "Any job: runs Irregular and Rectangles and keeps the cheapest valid layout",
+ "Any job: runs Irregular, Rectangles and Circles and keeps the cheapest valid layout",
() => new DefaultNestingEngine()
);
@@ -54,6 +55,12 @@ public static class NestingEngineRegistry
() => new IrregularNestingEngine()
);
+ Register(
+ "Circles",
+ "Round parts (blanks, washers, flanges): disc packing by enclosing circle",
+ () => new CirclesNestingEngine()
+ );
+
Register(
"FlatBar",
"Flat bar: parts cut in one row along each bar with the saw cut-list packer",
diff --git a/OpenNest.Engine/NestingEngines/CandidateProgress.cs b/OpenNest.Engine/NestingEngines/CandidateProgress.cs
new file mode 100644
index 00000000..be924858
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/CandidateProgress.cs
@@ -0,0 +1,29 @@
+#nullable enable
+using System;
+using OpenNest.Engine.Jobs;
+
+namespace OpenNest.Engine.NestingEngines;
+
+/// Forwards a candidate run's progress except plate commits, which belong to the result
+/// finally chosen; reports those once it is.
+internal sealed class CandidateProgress(IProgress inner) : IProgress
+{
+ public void Report(NestJobProgress value)
+ {
+ if (value != null && value.Stage != NestJobStage.PlateCommitted)
+ inner.Report(value);
+ }
+
+ public static void ReportCommits(NestJobResult result, IProgress? progress)
+ {
+ if (progress == null)
+ return;
+ var parts = 0;
+ for (var i = 0; i < result.Plates.Count; i++)
+ {
+ var plate = result.Plates[i];
+ parts += plate.Placements.Count;
+ progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts));
+ }
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/CandidateScore.cs b/OpenNest.Engine/NestingEngines/CandidateScore.cs
new file mode 100644
index 00000000..0cedd8e7
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/CandidateScore.cs
@@ -0,0 +1,35 @@
+#nullable enable
+using System.Linq;
+using OpenNest.Engine.Jobs;
+
+namespace OpenNest.Engine.NestingEngines;
+
+/// Ranks whole-job candidate results: a valid layout before an invalid one, then the
+/// fewest unplaced parts, then the lowest ; remaining ties keep
+/// candidate order.
+internal sealed record CandidateScore(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
+{
+ public static CandidateScore Of(NestJob job, NestJobResult result, int order)
+ {
+ var valid = NestLayoutCheck.Violations(job, result).Count == 0;
+ var placed = result.Plates.Sum(p => p.Placements.Count);
+ var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
+ // Cost needs every placement to name a known part, which only a valid result guarantees.
+ var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
+ return new CandidateScore(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
+ }
+
+ public bool IsBetterThan(CandidateScore other)
+ {
+ if (Valid != other.Valid)
+ return Valid;
+ if (Unplaced != other.Unplaced)
+ return Unplaced < other.Unplaced;
+ if (Priced && Cost != other.Cost)
+ return Cost < other.Cost;
+ var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
+ if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
+ return Cost < other.Cost;
+ return Order < other.Order;
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs
new file mode 100644
index 00000000..ebf5d384
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs
@@ -0,0 +1,95 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.CirclePacking;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Cutouts;
+
+namespace OpenNest.Engine.NestingEngines.Circles;
+
+///
+/// Round-part nesting engine: every part is packed as its minimum enclosing circle
+/// (), so blanks, washers, flanges and gears by their tip circle pack as discs
+/// with exact tangent placement. Rings already nested inside rings by the cutout pre-pass arrive as
+/// their outer frame and pack the same way.
+///
+/// Bids only on round jobs: when any readable part fills less than
+/// of its enclosing circle, it returns everything unplaced at once, so
+/// Default discards it at almost no cost and keeps routing such jobs to Irregular and Rectangles.
+///
+/// Sheet by sheet (), each available stock is packed bottom-left (rows)
+/// and left-bottom (columns), plain and seeded with a hexagonal lattice of the type with the most
+/// remaining area, and bottom-left over a square lattice of it; the candidate with the lowest
+/// estimated whole-job cost wins. The whole job runs once with that free choice and once
+/// preferring each offered stock, and the best result by is kept.
+/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
+///
+public sealed class CirclesNestingEngine : INestingEngine
+{
+ /// Parts below this outline-to-enclosing-circle area ratio waste too much as discs;
+ /// the same threshold the cutout circle packer uses.
+ public const double MinFillRatio = CutoutCirclePacker.MinFillRatio;
+
+ private static readonly (DiscOrder Order, DiscSeed Seed)[] Variants =
+ {
+ (DiscOrder.BottomLeft, DiscSeed.None), (DiscOrder.LeftBottom, DiscSeed.None),
+ (DiscOrder.BottomLeft, DiscSeed.Hexagonal), (DiscOrder.LeftBottom, DiscSeed.Hexagonal),
+ (DiscOrder.BottomLeft, DiscSeed.Square),
+ };
+
+ public NestJobResult Solve(
+ NestJob job,
+ IProgress? progress = null,
+ CancellationToken token = default
+ )
+ {
+ ArgumentNullException.ThrowIfNull(job);
+ token.ThrowIfCancellationRequested();
+
+ var types = new List(job.Parts.Count);
+ var remaining = new int[job.Parts.Count];
+ for (var i = 0; i < job.Parts.Count; i++)
+ {
+ var part = job.Parts[i];
+ var geometry = JobPartGeometry.TryRead(part.Geometry);
+ var rotations = part.Rotation.EnumerateAngles(maxSamples: 1);
+ var item = geometry == null ? null : Item.Create(geometry);
+ if (item != null && item.FillRatio < MinFillRatio)
+ return new NestJobResultBuilder(job, progress).Build(NestJobStopReason.NoPlacementFound);
+ // Unreadable parts keep a placeholder so type indices match requirement order.
+ types.Add(new DiscType(i, part, item ?? new Item(), rotations.Count > 0 ? rotations[0] : 0,
+ geometry?.MaterialArea ?? 0));
+ if (item != null && rotations.Count > 0 && job.Plates.Any(s => DiscPacker.Fits(s, types[i])))
+ remaining[i] = part.Quantity;
+ }
+
+ var demand = types.Select(t => new SheetDemand(t.Index, t.Id, t.Priority, t.MaterialArea)).ToArray();
+ // Look-ahead: sheet-by-sheet cost estimates can take a large sheet first and then need a
+ // second sheet anyway, so the whole job also runs preferring each offered stock in turn.
+ var forward = progress == null ? null : new CandidateProgress(progress);
+ CandidateScore? best = null;
+ var runs = new NestPlateStock?[] { null }.Concat(job.Plates.Count > 1 ? job.Plates : Array.Empty());
+ var order = 0;
+ foreach (var preferred in runs)
+ {
+ token.ThrowIfCancellationRequested();
+ var result = SheetByStock.Run(job, demand, remaining.ToArray(), Pack, forward, token,
+ preferred: preferred);
+ var scored = CandidateScore.Of(job, result, order++);
+ if (best == null || scored.IsBetterThan(best))
+ best = scored;
+ }
+ CandidateProgress.ReportCommits(best!.Result, progress);
+ return best.Result;
+
+ IEnumerable Pack(NestPlateStock stock, IReadOnlyList left)
+ {
+ if (!types.Any(t => left[t.Index] > 0 && DiscPacker.Fits(stock, t)))
+ yield break;
+ foreach (var (order, seed) in Variants)
+ yield return DiscPacker.Pack(types, left, stock, order, seed, token);
+ }
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs
new file mode 100644
index 00000000..6274a4e5
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs
@@ -0,0 +1,363 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.CirclePacking;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Cutouts;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.NestingEngines.Circles;
+
+/// A requested part packed as its enclosing circle at one allowed rotation.
+internal sealed record DiscType(int Index, NestJobPart Part, Item Item, double Rotation, double MaterialArea)
+{
+ public string Id => Part.Id;
+ public int Priority => Part.Priority;
+ public double Radius => Item.Radius;
+}
+
+/// Which way a sheet fills: rows from the bottom edge, or columns from the left edge.
+internal enum DiscOrder
+{
+ BottomLeft,
+ LeftBottom,
+}
+
+/// The lattice one type is tiled in before the greedy pass, if any. Staggered rows are
+/// densest on a large sheet; a square grid wins when only a few discs span the sheet.
+internal enum DiscSeed
+{
+ None,
+ Hexagonal,
+ Square,
+}
+
+///
+/// Packs discs onto one sheet. Each disc goes to the best feasible center tangent to two of
+/// {work-area walls, placed discs, clamp keep-outs}, the exact candidate set for circles, so no
+/// no-fit polygons are needed. Discs go lower priority number first, then largest first, so small
+/// discs land in the gaps between large ones. An optional hexagonal or square lattice of one type
+/// seeds the sheet before the greedy pass fills the rest.
+///
+/// Every disc keeps the part spacing plus from every other disc, and
+/// its radius from the work-area edges and from every clamp zone grown by the bottom-edge
+/// clearance. Radii are , which covers the layout check's outward flattening,
+/// so packing is never more lenient than the check. Deterministic.
+internal static class DiscPacker
+{
+ /// Extra gap beyond the spacing between discs, as in .
+ public const double Margin = CutoutCirclePacker.Margin;
+
+ private const double Epsilon = 1e-9;
+
+ public static SheetLayout Pack(IReadOnlyList types, IReadOnlyList remaining,
+ NestPlateStock stock, DiscOrder order, DiscSeed seed, CancellationToken token)
+ {
+ var left = remaining.ToArray();
+ var fits = types.Where(t => left[t.Index] > 0 && Fits(stock, t)).ToList();
+ var sheet = new Sheet(stock, types.Where(t => Fits(stock, t)).Select(t => t.Radius).DefaultIfEmpty(0).Max(), order);
+ if (seed != DiscSeed.None && LatticeType(fits, left) is { } lattice)
+ {
+ foreach (var center in Lattice(stock.WorkArea, lattice.Radius, sheet.Gap, order, seed))
+ {
+ token.ThrowIfCancellationRequested();
+ if (left[lattice.Index] == 0)
+ break;
+ if (!sheet.Feasible(center, lattice.Radius))
+ continue;
+ sheet.Place(lattice, center);
+ left[lattice.Index]--;
+ }
+ }
+ // Stable: equal radii keep requirement order.
+ foreach (var type in fits.OrderBy(t => t.Priority).ThenByDescending(t => t.Radius))
+ {
+ while (left[type.Index] > 0)
+ {
+ token.ThrowIfCancellationRequested();
+ // Space only shrinks, so a copy that finds no center leaves none for later copies.
+ if (sheet.Best(type.Radius) is not { } center)
+ break;
+ sheet.Place(type, center);
+ left[type.Index]--;
+ }
+ }
+ return sheet.Layout(types);
+ }
+
+ /// True when the disc fits the work area in both directions.
+ public static bool Fits(NestPlateStock stock, DiscType type)
+ {
+ var work = stock.WorkArea;
+ return 2 * type.Radius <= work.Right - work.Left + Epsilon
+ && 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
+ }
+
+ /// The top-priority type with the most remaining area, when it has copies to tile.
+ private static DiscType? LatticeType(IReadOnlyList fits, int[] left)
+ {
+ if (fits.Count == 0)
+ return null;
+ var top = fits.Min(t => t.Priority);
+ var best = fits.Where(t => t.Priority == top)
+ .OrderByDescending(t => left[t.Index] * t.Radius * t.Radius)
+ .First();
+ return left[best.Index] >= 2 ? best : null;
+ }
+
+ /// Lattice centers in fill order: rows along X for bottom-left, columns along Y for
+ /// left-bottom. Neighbors sit exactly one pitch apart.
+ private static IEnumerable Lattice(Box work, double r, double gap, DiscOrder order, DiscSeed seed)
+ {
+ var pitch = 2 * r + gap;
+ var hexagonal = seed == DiscSeed.Hexagonal;
+ var step = hexagonal ? pitch * System.Math.Sqrt(3) / 2 : pitch;
+ var rows = order == DiscOrder.BottomLeft;
+ var (along0, along1) = rows ? (work.Left + r, work.Right - r) : (work.Bottom + r, work.Top - r);
+ var (across0, across1) = rows ? (work.Bottom + r, work.Top - r) : (work.Left + r, work.Right - r);
+ for (var line = 0; across0 + line * step <= across1 + Epsilon; line++)
+ {
+ var across = across0 + line * step;
+ var shift = hexagonal && line % 2 == 1 ? pitch / 2 : 0;
+ for (var along = along0 + shift; along <= along1 + Epsilon; along += pitch)
+ yield return rows ? new Vector(along, across) : new Vector(across, along);
+ }
+ }
+
+ private sealed class Sheet
+ {
+ private readonly NestPlateStock stock;
+ private readonly Box work;
+ private readonly Box[] clamps;
+ private readonly double cell;
+ private readonly DiscOrder order;
+ private readonly Dictionary<(long, long), List> grid = new();
+ private readonly List<(DiscType Type, Vector Center)> placed = new();
+ // A disc with no feasible tangent center for radius r has none for any larger radius:
+ // shrinking a feasible larger disc toward the same tangent point keeps it feasible.
+ private readonly List deadFrom = new();
+
+ public Sheet(NestPlateStock stock, double maxRadius, DiscOrder order)
+ {
+ this.stock = stock;
+ work = stock.WorkArea;
+ // The layout check grows each zone by the bottom-edge clearance as a box, square
+ // corners included, so discs keep their radius from that grown box.
+ var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
+ clamps = stock.ClampZones.Select(z => z.Bounds.Offset(clearance)).ToArray();
+ Gap = stock.PartSpacing + Margin;
+ // Any disc within reach of a new one lies in the adjacent cells.
+ cell = System.Math.Max(2 * maxRadius + Gap, 1e-6);
+ this.order = order;
+ }
+
+ public double Gap { get; }
+
+ public void Place(DiscType type, Vector center)
+ {
+ var key = Key(center);
+ if (!grid.TryGetValue(key, out var list))
+ grid[key] = list = new List();
+ list.Add(placed.Count);
+ placed.Add((type, center));
+ deadFrom.Add(double.PositiveInfinity);
+ }
+
+ public bool Feasible(Vector p, double r)
+ {
+ if (p.X < work.Left + r - Epsilon || p.X > work.Right - r + Epsilon
+ || p.Y < work.Bottom + r - Epsilon || p.Y > work.Top - r + Epsilon)
+ return false;
+ foreach (var zone in clamps)
+ if (Distance(p, zone) < r - Epsilon)
+ return false;
+ foreach (var j in Near(p, 1))
+ if (placed[j].Center.DistanceTo(p) < placed[j].Type.Radius + r + Gap - Epsilon)
+ return false;
+ return true;
+ }
+
+ /// The best feasible center for a disc of radius , or null.
+ public Vector? Best(double r)
+ {
+ var vertical = new List { work.Left + r, work.Right - r };
+ var horizontal = new List { work.Bottom + r, work.Top - r };
+ var corners = new List();
+ foreach (var zone in clamps)
+ {
+ vertical.Add(zone.Left - r);
+ vertical.Add(zone.Right + r);
+ horizontal.Add(zone.Bottom - r);
+ horizontal.Add(zone.Top + r);
+ corners.Add(new Circle(new Vector(zone.Left, zone.Bottom), r));
+ corners.Add(new Circle(new Vector(zone.Right, zone.Bottom), r));
+ corners.Add(new Circle(new Vector(zone.Left, zone.Top), r));
+ corners.Add(new Circle(new Vector(zone.Right, zone.Top), r));
+ }
+
+ Vector? best = null;
+ void Consider(Vector p)
+ {
+ if ((best is not { } current || Better(p, current)) && Feasible(p, r))
+ best = p;
+ }
+
+ foreach (var x in vertical)
+ foreach (var y in horizontal)
+ Consider(new Vector(x, y));
+ for (var a = 0; a < corners.Count; a++)
+ {
+ foreach (var p in Lines(corners[a], vertical, horizontal))
+ Consider(p);
+ for (var b = a + 1; b < corners.Count; b++)
+ foreach (var p in Intersections(corners[a], corners[b]))
+ Consider(p);
+ }
+
+ for (var i = 0; i < placed.Count; i++)
+ {
+ if (r >= deadFrom[i] - Epsilon)
+ continue;
+ var around = new Circle(placed[i].Center, placed[i].Type.Radius + r + Gap);
+ var generated = false;
+ var feasible = false;
+ void Tangent(Vector p)
+ {
+ generated = true;
+ if (!Feasible(p, r))
+ return;
+ feasible = true;
+ if (best is not { } current || Better(p, current))
+ best = p;
+ }
+
+ foreach (var p in Lines(around, vertical, horizontal))
+ Tangent(p);
+ foreach (var corner in corners)
+ foreach (var p in Intersections(around, corner))
+ Tangent(p);
+ foreach (var j in Near(around.Center, 2))
+ {
+ if (j <= i || r >= deadFrom[j] - Epsilon)
+ continue;
+ foreach (var p in Intersections(around,
+ new Circle(placed[j].Center, placed[j].Type.Radius + r + Gap)))
+ Tangent(p);
+ }
+ // A tangent circle that crosses nothing is either wholly free or wholly blocked;
+ // only one that crosses constraints yet keeps no feasible point is surely closed.
+ if (generated && !feasible)
+ deadFrom[i] = r;
+ }
+ return best;
+ }
+
+ public SheetLayout Layout(IReadOnlyList types)
+ {
+ var counts = new int[types.Count];
+ var poses = new List<(string PartId, double X, double Y, double Rotation)>(placed.Count);
+ Box? envelope = null;
+ foreach (var (type, center) in placed)
+ {
+ counts[type.Index]++;
+ var origin = center + Rotate(type.Item.PartOffset, type.Rotation);
+ poses.Add((type.Id, origin.X, origin.Y, type.Rotation));
+ var box = new Box(center.X - type.Radius, center.Y - type.Radius, 2 * type.Radius, 2 * type.Radius);
+ envelope = envelope == null ? box : Union(envelope, box);
+ }
+ // Summed per type in type order, the form of the stock loop's demand area.
+ var area = types.Sum(t => counts[t.Index] * t.MaterialArea);
+ return new SheetLayout(stock, poses, area, envelope);
+ }
+
+ private bool Better(Vector candidate, Vector current)
+ {
+ var (a, b) = order == DiscOrder.BottomLeft
+ ? (candidate.Y - current.Y, candidate.X - current.X)
+ : (candidate.X - current.X, candidate.Y - current.Y);
+ if (System.Math.Abs(a) > Epsilon)
+ return a < 0;
+ return b < -Epsilon;
+ }
+
+ private (long, long) Key(Vector p) =>
+ ((long)System.Math.Floor((p.X - work.Left) / cell), (long)System.Math.Floor((p.Y - work.Bottom) / cell));
+
+ private IEnumerable Near(Vector p, int cells)
+ {
+ var (kx, ky) = Key(p);
+ for (var dx = -cells; dx <= cells; dx++)
+ for (var dy = -cells; dy <= cells; dy++)
+ if (grid.TryGetValue((kx + dx, ky + dy), out var list))
+ foreach (var j in list)
+ yield return j;
+ }
+
+ private static double Distance(Vector p, Box box)
+ {
+ var dx = System.Math.Max(0, System.Math.Max(box.Left - p.X, p.X - box.Right));
+ var dy = System.Math.Max(0, System.Math.Max(box.Bottom - p.Y, p.Y - box.Top));
+ return System.Math.Sqrt(dx * dx + dy * dy);
+ }
+ }
+
+ private static IEnumerable Lines(Circle circle, List vertical, List horizontal)
+ {
+ var c = circle.Center;
+ var r = circle.Radius;
+ foreach (var x in vertical)
+ {
+ var d = x - c.X;
+ if (System.Math.Abs(d) > r)
+ continue;
+ var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
+ yield return new Vector(x, c.Y - h);
+ if (h > Epsilon)
+ yield return new Vector(x, c.Y + h);
+ }
+ foreach (var y in horizontal)
+ {
+ var d = y - c.Y;
+ if (System.Math.Abs(d) > r)
+ continue;
+ var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
+ yield return new Vector(c.X - h, y);
+ if (h > Epsilon)
+ yield return new Vector(c.X + h, y);
+ }
+ }
+
+ private static IEnumerable Intersections(Circle a, Circle b)
+ {
+ var d = a.Center.DistanceTo(b.Center);
+ if (d < Epsilon || d > a.Radius + b.Radius || d < System.Math.Abs(a.Radius - b.Radius))
+ yield break;
+ var along = (a.Radius * a.Radius - b.Radius * b.Radius + d * d) / (2 * d);
+ var across = System.Math.Sqrt(System.Math.Max(0, a.Radius * a.Radius - along * along));
+ var ux = (b.Center.X - a.Center.X) / d;
+ var uy = (b.Center.Y - a.Center.Y) / d;
+ var mx = a.Center.X + ux * along;
+ var my = a.Center.Y + uy * along;
+ yield return new Vector(mx - uy * across, my + ux * across);
+ if (across > Epsilon)
+ yield return new Vector(mx + uy * across, my - ux * across);
+ }
+
+ private static Vector Rotate(Vector v, double angle)
+ {
+ var sine = System.Math.Sin(angle);
+ var cosine = System.Math.Cos(angle);
+ return new Vector(v.X * cosine - v.Y * sine, v.X * sine + v.Y * cosine);
+ }
+
+ private static Box Union(Box a, Box b)
+ {
+ var l = System.Math.Min(a.Left, b.Left);
+ var bo = System.Math.Min(a.Bottom, b.Bottom);
+ var r = System.Math.Max(a.Right, b.Right);
+ var t = System.Math.Max(a.Top, b.Top);
+ return new Box(l, bo, r - l, t - bo);
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs
index d978427a..0504fa04 100644
--- a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs
+++ b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using OpenNest.Engine.Jobs;
+using OpenNest.Engine.NestingEngines.Circles;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
@@ -16,8 +17,10 @@ namespace OpenNest.Engine.NestingEngines.Default;
/// layout before an invalid one, then the fewest unplaced parts, then the lowest
/// ; remaining ties keep candidate order.
///
-/// Candidates are Irregular, then Rectangles. Neither wins every job: Rectangles packs each part
-/// as its box, which is often best for plain plates and is a cheap, valid fallback for the rest.
+/// Candidates are Irregular, then Rectangles, then Circles. Neither of the first two wins every
+/// job: Rectangles packs each part as its box, which is often best for plain plates and is a
+/// cheap, valid fallback for the rest. Circles bids only when every part is round, and otherwise
+/// returns everything unplaced at once.
/// A candidate that throws or returns no result is skipped; when every candidate throws, the
/// first exception is rethrown. Cancellation stops the search. Candidates' plate commits are not forwarded; the chosen
/// result's commits are reported once it is selected.
@@ -29,7 +32,10 @@ public sealed class DefaultNestingEngine : INestingEngine
private readonly IReadOnlyList> candidates;
public DefaultNestingEngine()
- : this(new Func[] { () => new IrregularNestingEngine(), () => new RectanglesNestingEngine() })
+ : this(new Func[]
+ {
+ () => new IrregularNestingEngine(), () => new RectanglesNestingEngine(), () => new CirclesNestingEngine(),
+ })
{
}
@@ -51,7 +57,7 @@ public sealed class DefaultNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var forward = progress == null ? null : new CandidateProgress(progress);
- Scored? best = null;
+ CandidateScore? best = null;
ExceptionDispatchInfo? firstFailure = null;
for (var order = 0; order < candidates.Count; order++)
@@ -71,7 +77,7 @@ public sealed class DefaultNestingEngine : INestingEngine
if (result == null)
continue;
- var scored = Scored.Of(job, result, order);
+ var scored = CandidateScore.Of(job, result, order);
if (best == null || scored.IsBetterThan(best))
best = scored;
}
@@ -83,57 +89,7 @@ public sealed class DefaultNestingEngine : INestingEngine
throw new InvalidOperationException("No candidate engine returned a result.");
}
- ReportCommits(best.Result, progress);
+ CandidateProgress.ReportCommits(best.Result, progress);
return best.Result;
}
-
- private static void ReportCommits(NestJobResult result, IProgress? progress)
- {
- if (progress == null)
- return;
- var parts = 0;
- for (var i = 0; i < result.Plates.Count; i++)
- {
- var plate = result.Plates[i];
- parts += plate.Placements.Count;
- progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts));
- }
- }
-
- private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
- {
- public static Scored Of(NestJob job, NestJobResult result, int order)
- {
- var valid = NestLayoutCheck.Violations(job, result).Count == 0;
- var placed = result.Plates.Sum(p => p.Placements.Count);
- var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
- // Cost needs every placement to name a known part, which only a valid result guarantees.
- var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
- return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
- }
-
- public bool IsBetterThan(Scored other)
- {
- if (Valid != other.Valid)
- return Valid;
- if (Unplaced != other.Unplaced)
- return Unplaced < other.Unplaced;
- if (Priced && Cost != other.Cost)
- return Cost < other.Cost;
- var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
- if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
- return Cost < other.Cost;
- return Order < other.Order;
- }
- }
-
- /// Forwards a candidate's progress except plate commits, which belong to the chosen result.
- private sealed class CandidateProgress(IProgress inner) : IProgress
- {
- public void Report(NestJobProgress value)
- {
- if (value != null && value.Stage != NestJobStage.PlateCommitted)
- inner.Report(value);
- }
- }
}
diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
index 046ae068..eaefca4f 100644
--- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
+++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
@@ -16,10 +16,9 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts
/// are still placed validly, only as their bounding boxes; they are not nested into each other.
///
-/// Sheet by sheet, each available stock is packed under several free-space scoring rules and
-/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
-/// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
-/// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates.
+/// Sheet by sheet (), each available stock is packed under several
+/// free-space scoring rules and two pick modes (best-fitting box anywhere, or largest type first),
+/// and the candidate with the lowest estimated whole-job cost wins.
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
///
public sealed class RectanglesNestingEngine : INestingEngine
@@ -52,130 +51,23 @@ public sealed class RectanglesNestingEngine : INestingEngine
if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o))))
remaining[t.Index] = 0;
- var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
- var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
- var result = new NestJobResultBuilder(job, progress);
- NestJobStopReason reason;
+ var demand = types.Select(t => new SheetDemand(t.Index, t.Id, t.Priority, t.MaterialArea)).ToArray();
+ return SheetByStock.Run(job, demand, remaining, Pack, progress, token, BatchSelected);
- while (true)
+ IEnumerable Pack(NestPlateStock stock, IReadOnlyList left)
{
- token.ThrowIfCancellationRequested();
- if (remaining.All(r => r == 0))
- {
- reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
- break;
- }
- if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap)
- {
- reason = NestJobStopReason.PlateLimitReached;
- break;
- }
-
- var trials = new List<(SheetPlan Plan, double Net)>();
- foreach (var stock in job.Plates)
- {
- token.ThrowIfCancellationRequested();
- if (stock.Quantity is int available && used[stock.Id] >= available)
- continue;
- progress?.Report(new NestJobProgress(
- NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0));
- foreach (var mode in Modes)
- foreach (var rule in Rules)
- {
- var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
- SheetPacked?.Invoke(mode);
- if (plan.Parts.Count > 0)
- trials.Add((plan, NetCost(job, plan)));
- }
- }
-
- if (trials.Count == 0)
- {
- var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
- reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
- break;
- }
-
- var chosen = Choose(types, remaining, trials);
- var poses = chosen.Poses().ToArray();
- var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
- var counts = chosen.Parts.GroupBy(p => p.Type.Id, StringComparer.Ordinal)
- .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
- var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
// Both private MaxRects modes return only after their normal demand/no-fit search;
// neither returns partial/budget-limited output. This is not a geometric impossibility proof.
- var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
- chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - result.SheetsUsed(job));
- if (copies > 1)
- {
- try
+ foreach (var mode in Modes)
+ foreach (var rule in Rules)
{
- NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
- new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
+ var plan = SheetPacker.Pack(types, left, stock, rule, mode, token);
+ SheetPacked?.Invoke(mode);
+ yield return new SheetLayout(stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope);
}
- catch (InvalidOperationException)
- {
- // Keep the original one-sheet path if the candidate validator cannot certify
- // reuse (including its stricter work-area tolerance). Final job validation still applies.
- copies = 1;
- }
- }
- BatchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, result.SheetsUsed(job), false));
- for (var copy = 0; copy < copies; copy++)
- {
- token.ThrowIfCancellationRequested();
- result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
- }
- used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
- foreach (var type in types)
- if (counts.TryGetValue(type.Id, out var count))
- remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
}
-
- return result.Build(reason);
}
- ///
- /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
- /// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
- /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
- ///
- private static SheetPlan Choose(
- IReadOnlyList types, int[] remaining, List<(SheetPlan Plan, double Net)> trials)
- {
- var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
- var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
- return trials
- .Select((t, order) => (t.Plan, order,
- Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)))
- .OrderBy(t => PriorityDebt(types, remaining, t.Plan))
- .ThenBy(t => t.Estimate)
- .ThenByDescending(t => t.Plan.MaterialArea)
- .ThenBy(t => t.order)
- .First()
- .Plan;
- }
-
- ///
- /// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
- /// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet
- /// can never win by serving a later tier at the expense of an earlier one.
- ///
- private static int PriorityDebt(IReadOnlyList types, int[] remaining, SheetPlan plan)
- {
- var active = types.Where(t => remaining[t.Index] > 0).ToList();
- if (active.Count == 0)
- return 0;
- var top = active.Min(t => t.Priority);
- var placed = plan.Parts.Count(p => p.Type.Priority == top);
- return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
- }
-
- private static double NetCost(NestJob job, SheetPlan plan) =>
- plan.Envelope is { } envelope
- ? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
- : NestJobCost.GrossSheetCost(plan.Stock);
-
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{
var work = stock.WorkArea;
@@ -183,9 +75,3 @@ public sealed class RectanglesNestingEngine : INestingEngine
&& o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance;
}
}
-
-internal static class ResultBuilderExtensions
-{
- public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) =>
- job.Plates.Sum(builder.SheetsUsed);
-}
diff --git a/OpenNest.Engine/NestingEngines/SheetByStock.cs b/OpenNest.Engine/NestingEngines/SheetByStock.cs
new file mode 100644
index 00000000..127bf921
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/SheetByStock.cs
@@ -0,0 +1,179 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.NestingEngines;
+
+/// A requested part type as the stock loop sees it; addresses the
+/// remaining-demand array.
+internal sealed record SheetDemand(int Index, string Id, int Priority, double MaterialArea);
+
+/// One proposed single-sheet layout from an engine's packer.
+/// Material placed, summed per type in type order so equal layouts get
+/// bit-identical areas (placement-order rounding differs across platform math libraries).
+/// Bounds of the placed material, or null to price the gross sheet.
+internal sealed record SheetLayout(
+ NestPlateStock Stock,
+ IReadOnlyList<(string PartId, double X, double Y, double Rotation)> Poses,
+ double MaterialArea,
+ Box? Envelope);
+
+///
+/// Sheet-by-sheet stock selection shared by engines that propose whole single-sheet layouts.
+/// Each round asks the engine for layouts of the remaining demand on every available stock and
+/// commits the one with the lowest estimated whole-job cost: its salvage-credited net cost
+/// () plus the remaining demand priced at the best net-cost-per-material
+/// ratio seen among the candidates. A completed layout repeats while demand and stock allow it.
+/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
+///
+internal static class SheetByStock
+{
+ /// Demand per type index; updated as sheets commit. Zero a type the
+ /// engine can never place before calling.
+ /// Layouts of the given remaining demand on one stock. Each must be the
+ /// packer's completed search, not budget-limited output, so a repeat is safe.
+ /// When set, only this stock is tried while it is available and
+ /// takes a layout; the others are tried only once it cannot.
+ public static NestJobResult Run(
+ NestJob job,
+ IReadOnlyList types,
+ int[] remaining,
+ Func, IEnumerable> pack,
+ IProgress? progress,
+ CancellationToken token,
+ Action? batchSelected = null,
+ NestPlateStock? preferred = null)
+ {
+ var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
+ var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
+ var priority = types.ToDictionary(t => t.Id, t => t.Priority, StringComparer.Ordinal);
+ var result = new NestJobResultBuilder(job, progress);
+ NestJobStopReason reason;
+
+ while (true)
+ {
+ token.ThrowIfCancellationRequested();
+ if (remaining.All(r => r == 0))
+ {
+ reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
+ break;
+ }
+ if (job.Options.MaxPlates is int cap && SheetsUsed(result, job) >= cap)
+ {
+ reason = NestJobStopReason.PlateLimitReached;
+ break;
+ }
+
+ var trials = new List<(SheetLayout Layout, double Net)>();
+ if (preferred != null)
+ Try(preferred, trials);
+ if (trials.Count == 0)
+ foreach (var stock in job.Plates)
+ if (!ReferenceEquals(stock, preferred))
+ Try(stock, trials);
+
+ if (trials.Count == 0)
+ {
+ var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
+ reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
+ break;
+ }
+
+ var chosen = Choose(types, remaining, priority, trials);
+ var poses = chosen.Poses;
+ var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
+ var counts = poses.GroupBy(p => p.PartId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
+ var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
+ var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
+ chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - SheetsUsed(result, job));
+ if (copies > 1)
+ {
+ try
+ {
+ NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
+ new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
+ }
+ catch (InvalidOperationException)
+ {
+ // Keep the original one-sheet path if the candidate validator cannot certify
+ // reuse (including its stricter work-area tolerance). Final job validation still applies.
+ copies = 1;
+ }
+ }
+ batchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, SheetsUsed(result, job), false));
+ for (var copy = 0; copy < copies; copy++)
+ {
+ token.ThrowIfCancellationRequested();
+ result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
+ }
+ used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
+ foreach (var type in types)
+ if (counts.TryGetValue(type.Id, out var count))
+ remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
+ }
+
+ return result.Build(reason);
+
+ void Try(NestPlateStock stock, List<(SheetLayout Layout, double Net)> trials)
+ {
+ token.ThrowIfCancellationRequested();
+ if (stock.Quantity is int available && used[stock.Id] >= available)
+ return;
+ progress?.Report(new NestJobProgress(
+ NestJobStage.EvaluatingCandidate, stock.Id, SheetsUsed(result, job), SheetsUsed(result, job), 0));
+ foreach (var layout in pack(stock, remaining))
+ if (layout.Poses.Count > 0)
+ trials.Add((layout, NetCost(job, layout)));
+ }
+ }
+
+ public static int SheetsUsed(NestJobResultBuilder builder, NestJob job) =>
+ job.Plates.Sum(builder.SheetsUsed);
+
+ ///
+ /// Picks the layout with the lowest estimated whole-job cost. Remaining demand is priced at the
+ /// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
+ /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
+ ///
+ private static SheetLayout Choose(IReadOnlyList types, int[] remaining,
+ IReadOnlyDictionary priority, List<(SheetLayout Layout, double Net)> trials)
+ {
+ var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
+ var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Layout.MaterialArea, 1e-12));
+ return trials
+ .Select((t, order) => (t.Layout, order,
+ Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Layout.MaterialArea) * bestRatio)))
+ .OrderBy(t => PriorityDebt(types, remaining, priority, t.Layout))
+ .ThenBy(t => t.Estimate)
+ .ThenByDescending(t => t.Layout.MaterialArea)
+ .ThenBy(t => t.order)
+ .First()
+ .Layout;
+ }
+
+ ///
+ /// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
+ /// demand this layout leaves unplaced. Layouts are ranked on this before cost, so a cheaper sheet
+ /// can never win by serving a later tier at the expense of an earlier one.
+ ///
+ private static int PriorityDebt(IReadOnlyList types, int[] remaining,
+ IReadOnlyDictionary priority, SheetLayout layout)
+ {
+ var active = types.Where(t => remaining[t.Index] > 0).ToList();
+ if (active.Count == 0)
+ return 0;
+ var top = active.Min(t => t.Priority);
+ var placed = layout.Poses.Count(p => priority[p.PartId] == top);
+ return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
+ }
+
+ private static double NetCost(NestJob job, SheetLayout layout) =>
+ layout.Envelope is { } envelope
+ ? NestJobCost.NetSheetCost(job.Options, layout.Stock, envelope)
+ : NestJobCost.GrossSheetCost(layout.Stock);
+}
diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs
index 1b032588..fb8d4f91 100644
--- a/OpenNest.Mcp/Tools/NestingTools.cs
+++ b/OpenNest.Mcp/Tools/NestingTools.cs
@@ -25,10 +25,11 @@ namespace OpenNest.Mcp.Tools
/// Built-in engines with what each suits; a test keeps this in step with the registry.
private const string JobEngines =
- "Whole-job engine. Default (used when omitted): suits any job; runs Irregular and Rectangles "
+ "Whole-job engine. Default (used when omitted): suits any job; runs Irregular, Rectangles and Circles "
+ "and keeps the cheapest valid layout. "
+ "Irregular: irregular profiles; no-fit-polygon packing into notches and gaps, best-fit pairs. "
+ "Rectangles: plain and near-rectangular parts; packs each part as its bounding box, fastest. "
+ + "Circles: round parts (blanks, washers, flanges); packs each part as its enclosing circle; places nothing when any part is not round. "
+ "Fill: many copies of few drawings; lattice fill with pairs and rectangle best-fit. "
+ "Strip: Fill variant laying mixed drawings in strips. "
+ "Vertical Remnant, Horizontal Remnant: Fill variants keeping a right-side or top-side drop. "
diff --git a/README.md b/README.md
index faa9f8d5..371b37e7 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ Run `dotnet run --project OpenNest.Console -- --help` for the complete options a
## Nesting Engines
-Default compares the built-in Irregular and Rectangles engines and keeps the best valid whole-job layout. Other engines and plug-ins suit particular jobs; see [which engine to use](docs/nesting-engines.md). Desktop Auto Nest, console autonest, MCP autonest, and the API share an independent validation pipeline, including for plug-ins. [Automatic nesting and validation](docs/automatic-nesting.md) explains how callers handle invalid results.
+Default compares the built-in Irregular, Rectangles and Circles engines and keeps the best valid whole-job layout. Other engines and plug-ins suit particular jobs; see [which engine to use](docs/nesting-engines.md). Desktop Auto Nest, console autonest, MCP autonest, and the API share an independent validation pipeline, including for plug-ins. [Automatic nesting and validation](docs/automatic-nesting.md) explains how callers handle invalid results.
## File Format
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index ec714700..b68d2195 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -11,8 +11,9 @@ in `OpenNest.Engine/NestingEngines//`, its tests in `OpenNest.Engine.Tests
| Engine | Best for | Method |
|---|---|---|
-| Default | Any job; used when no engine is named | Runs Irregular, then Rectangles, checks both layouts with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep Irregular |
+| Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep the earlier engine |
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
+| Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan |
| FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
@@ -22,7 +23,8 @@ Neither Irregular nor Rectangles wins every job, even within its own lane, so De
rather than choosing by part shape; Rectangles adds little time and is also the fallback when an
Irregular layout fails the check. A candidate that throws is skipped. Default routes the whole job:
engines cannot share a sheet, so a job mixing plain and irregular parts goes to both engines whole.
-A future circle/ring engine joins Default as another candidate.
+Circles is a third candidate that bids only on round jobs (see below), so a job mixing round and
+other parts goes to Irregular and Rectangles.
Rectangles places irregular parts validly, but only as their bounding boxes; it never nests into a
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
@@ -166,6 +168,28 @@ Default inherits these fast paths by running the two engines; other engines and
keep their existing search behavior. Quantity-bearing presentation is a separate validated-pipeline
representation; repeating does not bypass whole-job validation or change cutting programs.
+## Circles
+
+Circles (`OpenNest.Engine/NestingEngines/Circles/`) packs every part as its minimum enclosing
+circle (`CirclePacking/Item`): the circle covers the layout check's outward-flattened perimeter,
+so packing is never more lenient than the check. When any readable part fills less than 0.8 of
+that circle (a square fills 0.64, a gear about 0.85), it returns everything unplaced without
+reporting progress, and Default discards it at almost no cost. Holes are not used here; rings in
+rings come from the cutout pre-pass, which hands the engine each nested chain as its outer frame.
+
+Each disc goes, lower priority number first and then largest first, to the best feasible center
+tangent to two of: the work-area walls, placed discs, and clamp zones grown by the bottom-edge
+clearance (square-cornered, as the layout check grows them). Discs keep the part spacing plus
+twice `NestTolerances.ValidationOutline` between them. Bottom-left fills rows and left-bottom
+fills columns; each also runs seeded with a hexagonal lattice of the type with the most remaining
+area, and bottom-left runs over a square lattice too, which wins when only a few discs span the
+sheet. A disc with no feasible tangent center for one radius is skipped for that radius and larger
+ones. Sheets and stock are chosen by the loop Rectangles uses (`NestingEngines/SheetByStock`):
+salvage-credited look-ahead cost, priority first, and repeated completed layouts. That per-sheet
+estimate can take a large sheet first and still need a second, so Circles runs the whole job once
+with a free stock choice and once preferring each offered stock, and keeps the best result as
+Default ranks candidates (valid, fewest unplaced, lowest cost).
+
## Filling cutouts
Every whole-job `NestPipeline.Run` (desktop Auto Nest, Console, MCP and API) first places parts