diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutCirclePackerTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutCirclePackerTests.cs new file mode 100644 index 00000000..69f14054 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutCirclePackerTests.cs @@ -0,0 +1,190 @@ +using OpenNest.Converters; +using OpenNest.Engine.CirclePacking; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Engine.Tests.NestingEngines; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +public class CutoutCirclePackerTests +{ + private const double Spacing = 0.25; + + [Fact] + public void SingleInsertSitsConcentric() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Part("disc", TestShapes.Disc(3), 1); + + var pose = Assert.Single(CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing)); + + Assert.Equal(0, pose.X, 6); + Assert.Equal(0, pose.Y, 6); + AssertPhysical(frame, new[] { (insert, new Vector()) }, new[] { pose }); + } + + // Optimal container-to-circle radius ratios for n equal circles; all are rings, with a + // center circle for n = 7..9. Six is left out: its ratio already holds seven. + [Theory] + [InlineData(2, 2.0)] + [InlineData(3, 2.1547)] + [InlineData(4, 2.4143)] + [InlineData(5, 2.7014)] + [InlineData(7, 3.0)] + [InlineData(8, 3.3049)] + [InlineData(9, 3.6132)] + public void EqualDiscsReachTheKnownOptimalCount(int n, double ratio) + { + var insert = JobBuilder.Part("disc", TestShapes.Disc(1), n + 3); + var radius = Item.Create(JobPartGeometry.Read(insert.Geometry)).Radius; + var gap = Spacing + CutoutCirclePacker.Margin; + // Container and circles grown by half the gap, so the ratio applies to them directly. + var hole = ratio * (radius + gap / 2) - gap / 2 + gap + Item.FlattenAllowance + 0.001; + var frame = JobBuilder.Part("frame", TestShapes.Ring(2 * hole + 4, 2 * hole), 1); + + var poses = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing); + + Assert.Equal(n, poses.Count); + Assert.Equal(Enumerable.Range(0, n), poses.Select(p => p.InstanceIndex)); + AssertPhysical(frame, new[] { (insert, new Vector()) }, poses); + } + + [Fact] + public void MixedSizesFillTheGapsLargestFirst() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(40, 30), 1); + var large = JobBuilder.Part("large", TestShapes.Disc(4), 6); + var small = JobBuilder.Part("small", TestShapes.Disc(1), 20); + + var poses = CutoutCirclePacker.Fill(frame, 0, new[] { small, large }, Spacing); + + Assert.Equal(large.Quantity, poses.Count(p => p.PartId == large.Id)); + Assert.True(poses.Count(p => p.PartId == small.Id) >= 10); + AssertPhysical(frame, new[] { (small, new Vector()), (large, new Vector()) }, poses); + } + + [Fact] + public void RotatedOffCenterInsertMapsItsCenterIntoTheHole() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Part("disc", FromEntities(new Circle(50, 20, 2)), 2, + RotationPolicy.Fixed(System.Math.PI / 2)); + + var poses = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing); + + Assert.Equal(2, poses.Count); + Assert.All(poses, p => Assert.Equal(System.Math.PI / 2, p.Rotation, 9)); + AssertPhysical(frame, new[] { (insert, new Vector(50, 20)) }, poses); + } + + [Fact] + public void RouterTakesACompleteCirclePacking() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(30, 20), 1); + var insert = JobBuilder.Part("disc", TestShapes.Disc(3), 7); + var circles = CutoutCirclePacker.Fill(frame, 0, new[] { insert }, Spacing); + var origins = new List(); + + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, Spacing, (_, lattice) => origins.Add(lattice)); + + // Ratio 10 / 3.25 > 3: six around one in the center. + Assert.Equal(7, circles.Count); + Assert.Equal(circles, poses); + Assert.All(origins, Assert.False); + } + + [Fact] + public void SquareHolesAndSquareInsertsAreNotRound() + { + var square = JobPartGeometry.Read(JobBuilder.Part("frame", Square(20, 10), 1).Geometry); + var ring = JobPartGeometry.Read(JobBuilder.Part("ring", TestShapes.Ring(20, 10), 1).Geometry); + var disc = JobBuilder.Part("disc", TestShapes.Disc(2), 1); + + Assert.Null(CutoutCirclePacker.RoundHole(square.Cutouts[0])); + Assert.False(CutoutCirclePacker.Applies(square.Cutouts[0], new[] { disc })); + Assert.True(CutoutCirclePacker.Applies(ring.Cutouts[0], new[] { disc })); + Assert.False(CutoutCirclePacker.Applies(ring.Cutouts[0], + new[] { disc, JobBuilder.Rectangle("square", 2, 2, 1) })); + Assert.Empty(CutoutCirclePacker.Fill(JobBuilder.Part("frame", Square(20, 10), 1), 0, + new[] { disc }, Spacing)); + } + + [Fact] + public void InsertLargerThanTheHolePlacesNothing() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + + Assert.Empty(CutoutCirclePacker.Fill(frame, 0, + new[] { JobBuilder.Part("disc", TestShapes.Disc(4.8), 1) }, Spacing)); + } + + [Fact] + public void CancellationIsNotNoFit() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.Throws(() => CutoutCirclePacker.Fill( + JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1), 0, + new[] { JobBuilder.Part("disc", TestShapes.Disc(1), 3) }, Spacing, cts.Token)); + } + + /// The layout check on a sheet, plus an independent analytic disc oracle. + private static void AssertPhysical(NestJobPart frame, (NestJobPart Part, Vector Center)[] inserts, + IReadOnlyList poses) + { + var frameGeometry = JobPartGeometry.Read(frame.Geometry); + var bounds = frameGeometry.Bounds; + var stock = JobBuilder.Stock("sheet", bounds.Width + 2, bounds.Length + 2, Spacing); + var parts = new[] { frame }.Concat(inserts.Select(i => i.Part)).ToArray(); + var job = JobBuilder.Job(parts, new[] { stock }); + var dx = 1 - bounds.Left; + var dy = 1 - bounds.Bottom; + var placements = new[] { new NestJobPlacement(frame.Id, 0, dx, dy, 0) } + .Concat(poses.Select(p => p with { X = p.X + dx, Y = p.Y + dy })).ToArray(); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, placements) }, + parts.Select(part => + { + var placed = placements.Count(p => p.PartId == part.Id); + return new PartFulfillment(part.Id, part.Quantity, placed, part.Quantity - placed); + }).ToArray(), + new[] { new StockUsage(stock.Id, 1, null) }); + LayoutAssert.Valid(job, result); + + var hole = CutoutCirclePacker.RoundHole(frameGeometry.Cutouts[0])!; + var discs = poses.Select(pose => + { + var (part, local) = inserts.Single(i => i.Part.Id == pose.PartId); + var radius = JobPartGeometry.Read(part.Geometry).Bounds.Width / 2; + var cos = System.Math.Cos(pose.Rotation); + var sin = System.Math.Sin(pose.Rotation); + var center = new Vector(pose.X + local.X * cos - local.Y * sin, pose.Y + local.X * sin + local.Y * cos); + return (Center: center, Radius: radius); + }).ToArray(); + foreach (var disc in discs) + Assert.True(disc.Center.DistanceTo(hole.Center) + disc.Radius <= hole.Radius - Spacing, + $"disc at {disc.Center} reaches the hole wall"); + for (var i = 0; i < discs.Length; i++) + for (var j = i + 1; j < discs.Length; j++) + Assert.True(discs[i].Center.DistanceTo(discs[j].Center) >= discs[i].Radius + discs[j].Radius + Spacing, + $"discs at {discs[i].Center} and {discs[j].Center} are closer than spacing"); + } + + private static OpenNest.CNC.Program FromEntities(params Entity[] entities) => + ConvertGeometry.ToProgram(entities.ToList()); + + private static OpenNest.CNC.Program Square(double outer, double inner) + { + var program = TestShapes.Rectangle(outer, outer); + var a = (outer - inner) / 2; + var b = a + inner; + program.MoveTo(a, a); + program.LineTo(a, b); + program.LineTo(b, b); + program.LineTo(b, a); + program.LineTo(a, a); + return program; + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs new file mode 100644 index 00000000..f83383a3 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs @@ -0,0 +1,244 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.CirclePacking; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Circle-in-circle proposal for a round cutout: every insert is packed by its enclosing +/// circle (), so the hole and inserts are discs and placement is analytic. +/// Never changes demand, stock or a live plate. +/// Two greedy passes over the copies, largest first, choose among candidate centers +/// tangent to two of {hole wall, placed discs} plus the hole's center. One grows from the +/// center, so a single insert sits concentric and keeps its own bore free for a later level; +/// the other lays rings against the wall first, which reproduces the ring-shaped optimal +/// packings of up to nine equal circles. The higher count wins, ties keep the centered pass. +/// Every pose is certified with before it is returned. +internal static class CutoutCirclePacker +{ + /// Inserts whose outline fills less of their enclosing circle stay with the lattice + /// and NFP proposals; a gear (about 0.85) packs by its tip circle, a square (0.64) does not. + internal const double MinFillRatio = 0.8; + + /// Extra gap beyond the spacing on every pair and at the wall, covering the layout + /// check's flattening of the hole and of each insert. + internal const double Margin = 2 * NestTolerances.ValidationOutline; + + // Greedy cost grows with the cube of the copy count; beyond this, a round hole's surplus + // copies stay ordinary demand. + private const int MaxCopies = 256; + private const double Epsilon = 1e-9; + + private sealed record Disc(NestJobPart Part, Item Item, double Rotation, double Radius); + + /// The hole's center and radius when every entity of the cutout lies on one circle; + /// otherwise null. + internal static Circle? RoundHole(Shape cutout) + { + ArgumentNullException.ThrowIfNull(cutout); + if (!cutout.IsClosed() || cutout.Entities.Count == 0) + return null; + Circle? first = null; + foreach (var entity in cutout.Entities) + { + var circle = entity switch + { + Circle c => new Circle(c.Center, c.Radius), + Arc a => new Circle(a.Center, a.Radius), + _ => null, + }; + if (circle == null || !double.IsFinite(circle.Radius) || circle.Radius <= 0) + return null; + first ??= circle; + if (System.Math.Abs(circle.Radius - first.Radius) > Tolerance.Epsilon + || circle.Center.DistanceTo(first.Center) > Tolerance.Epsilon) + return null; + } + return first; + } + + /// True when the hole is round and every insert's enclosing circle is a fair model + /// of it, so this proposal is worth computing. + internal static bool Applies(Shape cutout, IReadOnlyList inserts) => + RoundHole(cutout) != null && inserts.Count > 0 + && inserts.All(p => Item.TryCreate(p.Geometry) is { FillRatio: >= MinFillRatio }); + + /// Frame-local poses inside one round cutout, or none when the cutout is not round + /// or nothing fits. Instance indices count per requirement from zero. + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(inserts); + token.ThrowIfCancellationRequested(); + if (!double.IsFinite(spacing) || spacing < 0) + return Array.Empty(); + var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + if (frameGeometry == null) + return Array.Empty(); + ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); + var hole = RoundHole(frameGeometry.Cutouts[cutoutIndex]); + if (hole == null) + return Array.Empty(); + + // The check flattens cutouts inscribed, so its hole may be an allowance smaller. + var wall = hole.Radius - Item.FlattenAllowance - spacing - Margin; + var discs = new List(); + var geometries = new Dictionary(StringComparer.Ordinal); + foreach (var part in inserts) + { + token.ThrowIfCancellationRequested(); + var geometry = JobPartGeometry.TryRead(part.Geometry); + if (geometry == null || part.Quantity <= 0) + continue; + var item = Item.Create(geometry); + var rotations = part.Rotation.EnumerateAngles(maxSamples: 1); + if (rotations.Count == 0 || item.Radius > wall) + continue; + geometries[part.Id] = geometry; + // No more copies than could cover the hole's area, so huge demand stays bounded. + var byArea = (int)System.Math.Min(part.Quantity, + System.Math.Floor(wall * wall / (item.Radius * item.Radius)) + 1); + for (var copy = 0; copy < byArea && discs.Count < MaxCopies; copy++) + discs.Add(new Disc(part, item, rotations[0], item.Radius)); + } + if (discs.Count == 0) + return Array.Empty(); + // Stable: equal radii keep caller requirement order. + discs = discs.OrderByDescending(d => d.Radius).ToList(); + + var gap = spacing + Margin; + var centered = Greedy(discs, wall, gap, fromCenter: true, token); + var walled = discs.Count > 1 ? Greedy(discs, wall, gap, fromCenter: false, token) : centered; + var chosen = walled.Count > centered.Count ? walled : centered; + + var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0); + var accepted = new List<(Disc Disc, Vector Center, NestJobPlacement Pose)>(); + foreach (var (disc, offset) in chosen) + { + token.ThrowIfCancellationRequested(); + var center = hole.Center + offset; + var origin = center + Rotate(disc.Item.PartOffset, disc.Rotation); + var pose = new NestJobPlacement(disc.Part.Id, + accepted.Count(p => p.Pose.PartId == disc.Part.Id), origin.X, origin.Y, disc.Rotation); + var geometry = geometries[disc.Part.Id]; + if (!NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing)) + continue; + // Discs farther apart than their radii plus twice the spacing cannot touch the + // check's inflated outlines; the analytic gap already proves them clear. + if (accepted.Any(other => other.Center.DistanceTo(center) + < other.Disc.Radius + disc.Radius + 2 * spacing + Margin + && !NestLayoutCheck.Clears(geometries[other.Disc.Part.Id], other.Pose, geometry, pose, spacing))) + continue; + accepted.Add((disc, center, pose)); + } + token.ThrowIfCancellationRequested(); + return accepted.Select(p => p.Pose).ToArray(); + } + + /// Places each disc in order at its best feasible candidate; a disc with no + /// feasible candidate is skipped so a smaller one may still fit. Centers are relative to + /// the hole's center; bounds center distance plus radius. + private static List<(Disc Disc, Vector Center)> Greedy(IReadOnlyList discs, double wall, + double gap, bool fromCenter, CancellationToken token) + { + var placed = new List<(Disc Disc, Vector Center)>(); + foreach (var disc in discs) + { + token.ThrowIfCancellationRequested(); + var r = disc.Radius; + var ring = wall - r; + Vector? best = null; + foreach (var candidate in Candidates(placed, ring, r, gap, fromCenter)) + { + if (!Feasible(candidate) || (best is { } current && !Better(candidate, current))) + continue; + best = candidate; + } + if (best is { } chosen) + placed.Add((disc, chosen)); + + bool Feasible(Vector p) + { + if (p.DistanceTo(0, 0) > ring + Epsilon) + return false; + foreach (var (other, center) in placed) + if (center.DistanceTo(p) < other.Radius + r + gap - Epsilon) + return false; + return true; + } + } + return placed; + + bool Better(Vector candidate, Vector current) + { + // Centered pass: nearest the center; wall pass: nearest the wall. Then the lowest + // angle counterclockwise from straight down, so rings close up in order. + var radial = candidate.DistanceTo(0, 0) - current.DistanceTo(0, 0); + if (System.Math.Abs(radial) > Epsilon) + return fromCenter ? radial < 0 : radial > 0; + return Turn(candidate) < Turn(current) - Epsilon; + } + } + + private static IEnumerable Candidates(List<(Disc Disc, Vector Center)> placed, double ring, + double r, double gap, bool fromCenter) + { + yield return new Vector(0, 0); + if (placed.Count == 0) + { + if (!fromCenter) + yield return new Vector(0, -ring); + yield break; + } + for (var i = 0; i < placed.Count; i++) + { + var (a, ca) = placed[i]; + var da = a.Radius + r + gap; + foreach (var p in Intersections(new Vector(0, 0), ring, ca, da)) + yield return p; + for (var j = i + 1; j < placed.Count; j++) + { + var (b, cb) = placed[j]; + foreach (var p in Intersections(ca, da, cb, b.Radius + r + gap)) + yield return p; + } + } + } + + private static IEnumerable Intersections(Vector c0, double r0, Vector c1, double r1) + { + var d = c0.DistanceTo(c1); + if (d < Epsilon || d > r0 + r1 || d < System.Math.Abs(r0 - r1)) + yield break; + var along = (r0 * r0 - r1 * r1 + d * d) / (2 * d); + var across = System.Math.Sqrt(System.Math.Max(0, r0 * r0 - along * along)); + var ux = (c1.X - c0.X) / d; + var uy = (c1.Y - c0.Y) / d; + var mx = c0.X + ux * along; + var my = c0.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 double Turn(Vector p) + { + if (p.DistanceTo(0, 0) < Epsilon) + return -1; + var angle = System.Math.Atan2(p.X, -p.Y); + return angle < 0 ? angle + 2 * System.Math.PI : angle; + } + + 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); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index 53caeaa7..b35f6125 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -9,7 +9,9 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. /// Area ratio selects search order, not placement permission. The small-insert path /// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search; -/// the large-insert path tries NFP first. All accepted poses are clearance-checked. +/// the large-insert path tries NFP first. A round hole whose inserts are all round also gets a +/// circle-in-circle proposal for the whole list, kept only when it places more copies. +/// All accepted poses are clearance-checked. /// Fill's equal-score pose nondeterminism remains PM c98c21bd. internal static class CutoutRouter { @@ -23,7 +25,7 @@ internal static class CutoutRouter Fill(frame, cutoutIndex, inserts, spacing, null, token); /// Optional internal diagnostic receives only poses from the chosen proposal, - /// after indexing; true marks a retained lattice seed, false a residual NFP pose. + /// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose. internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, IReadOnlyList inserts, double spacing, Action? observeAccepted, CancellationToken token = default) @@ -48,7 +50,16 @@ internal static class CutoutRouter throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.", nameof(inserts)); + // Nothing can place more than all demand, so a complete circle packing skips the + // lattice and NFP searches, which are slow on many round inserts. + var circles = CutoutCirclePacker.Applies(hole, inserts) + ? CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, token) + : null; + if (circles != null && circles.Count == inserts.Sum(p => (long)System.Math.Max(0, p.Quantity))) + return Circles(circles); + var accepted = new List(); + var observed = new List<(NestJobPlacement Pose, bool Lattice)>(); var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(); var holeArea = System.Math.Abs(hole.Area()); // Preserve caller requirement order: priorities are a whole-job concern, not inferred here. @@ -97,7 +108,7 @@ internal static class CutoutRouter var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; accepted.Add(indexed); occupied.Add((geometry, indexed)); - observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice); + observed.Add((indexed, localIndex++ < retainedLattice)); } IReadOnlyList Complete(IReadOnlyList seed) @@ -127,7 +138,18 @@ internal static class CutoutRouter } } token.ThrowIfCancellationRequested(); + if (circles != null && circles.Count > accepted.Count) + return Circles(circles); + foreach (var (pose, lattice) in observed) + observeAccepted?.Invoke(pose, lattice); return accepted.ToArray(); + + IReadOnlyList Circles(IReadOnlyList poses) + { + foreach (var pose in poses) + observeAccepted?.Invoke(pose, false); + return poses; + } } private static bool SupportedGeometry(NestJobPart part) diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index e613f5e7..166968cf 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -196,6 +196,16 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. + +When the cutout is one circle and every insert fills at least 0.8 of its enclosing circle +(discs, washers, gears by their tip circle), `CutoutCirclePacker` first packs the whole insert +list as discs: largest first, at centers tangent to two of the hole wall and the placed discs, +or at the hole's center. It tries a center-out pass, so a single insert sits concentric, and a +wall-first pass, which reaches the optimal counts for up to nine equal discs. Each gap is the +spacing plus twice `NestTolerances.ValidationOutline`, and every pose passes `NestLayoutCheck`. +If it places all demand, the router skips the lattice and NFP searches, which are slow on many +round inserts. Otherwise it keeps whichever proposal places more copies, and the existing +proposals win ties. `CutoutPipelinePrepass` builds quantity-one proxy instances for eligible closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry reduction, shifts insert rotation policies into the local frame, and reserves certified router