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