using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Cutouts; using OpenNest.Engine.Tests.NestingEngines; using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; namespace OpenNest.Engine.Tests.Jobs.Cutouts; public class CutoutRouterTests { private static NestJobPart Frame() { var program = TestShapes.Rectangle(20, 20); program.MoveTo(5, 5); program.LineTo(5, 15); program.LineTo(15, 15); program.LineTo(15, 5); program.LineTo(5, 5); return JobBuilder.Part("frame", program, 1); } [Fact] public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds() { var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3); var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0)); var origins = new List<(NestJobPlacement Pose, bool Lattice)>(); var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25, (pose, lattice) => origins.Add((pose, lattice))); Assert.Equal(3, poses.Count(p => p.PartId == small.Id)); Assert.Single(poses, p => p.PartId == large.Id); Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice); Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice); Assert.Equal(poses, origins.Select(row => row.Pose)); Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { small, large }, poses); } [Fact] public void ImpossibleRemainderNeverDisplacesCertifiedCopies() { var frame = Frame(); var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0)); var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0)); var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25); Assert.Equal(3, together.Count); Assert.All(together, p => Assert.Equal(small.Id, p.PartId)); Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { small, impossible }, together); } [Fact] public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch() { var frame = Frame(); var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0)); var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0)); var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity); Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity); AssertPhysical(frame, new[] { small, large }, poses); } [Fact] public void ResidualNfpDoesNotOverlapTheFirstLargeInsert() { var frame = Frame(); var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0)); var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); Assert.Single(poses); AssertPhysical(frame, new[] { insert }, poses); } [Fact] public void NfpAddsOneMoreCopyToPartialShiftedLattice() { // Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted // lattice keeps 11; an independently certified NFP pose occupies its leftover. var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12); var origins = new List<(NestJobPlacement Pose, bool Lattice)>(); var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25, (pose, lattice) => origins.Add((pose, lattice))); Assert.Equal(12, poses.Count); Assert.Equal(11, origins.Count(row => row.Lattice)); Assert.Single(origins, row => !row.Lattice); Assert.Equal(poses, origins.Select(row => row.Pose)); Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { insert }, poses); } [Fact] public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy() { // 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy. var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3); Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio); var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); Assert.Equal(2, poses.Count); AssertPhysical(frame, new[] { insert }, poses); } [Fact] public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice() { var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0)); Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio); Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25)); var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); Assert.Single(poses); AssertPhysical(frame, new[] { insert }, poses); } [Fact] public void DuplicateRequirementIdsAreRejectedBeforePlacement() { var frame = Frame(); var insert = JobBuilder.Rectangle("same", 1, 1, 3); Assert.Throws(() => CutoutRouter.Fill(frame, 0, new[] { insert, insert }, 0.25)); } [Fact] public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose() { var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1); var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3); var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25); Assert.Equal(3, early.Count); var blocked = early[0] with { PartId = first.Id }; var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) }; var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25, CutoutLatticeFill.DefaultShiftSteps, occupied, default); Assert.Equal(3, later.Count); Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked, JobPartGeometry.Read(more.Geometry), p, 0.25))); } [Fact] public void TinyInsertDoesNotAllocateAnUnboundedLattice() { var frame = Frame(); var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0)); Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0)); var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0); Assert.Equal(3, poses.Count); } [Fact] public void CancellationIsNotNoFit() { using var cts = new CancellationTokenSource(); cts.Cancel(); Assert.Throws(() => CutoutRouter.Fill(Frame(), 0, new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token)); } private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts, IReadOnlyList poses) { var stock = JobBuilder.Stock("sheet", 22, 22, 0.25); var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock }); var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds; var offsetX = 1 - frameBounds.Left; var offsetY = 1 - frameBounds.Bottom; var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) } .Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray(); var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, new[] { new NestJobPlateResult(0, stock, placements) }, new[] { new PartFulfillment(frame.Id, 1, 1, 0) } .Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity, poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id)))) .ToArray(), new[] { new StockUsage(stock.Id, 1, null) }); LayoutAssert.Valid(job, result); var frameGeometry = JobPartGeometry.Read(frame.Geometry); var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0); foreach (var pose in poses) { var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry); Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25)); // Independent square-wall oracle: every corner of each rectangular insert clears the hole. var bounds = geometry.Bounds; foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom), (bounds.Right, bounds.Top), (bounds.Left, bounds.Top) }) { var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation); var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation); if (frameBounds.Left >= 0) { Assert.InRange(rx, 5.25, 14.75); Assert.InRange(ry, 5.25, 14.75); } else { var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right); Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001); } } } // Independent rectangle-vs-rectangle distance, not the production Clears predicate. for (var i = 0; i < poses.Count; i++) for (var j = i + 1; j < poses.Count; j++) { var a = Bounds(poses[i]); var b = Bounds(poses[j]); var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right)); var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top)); Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001, $"independent material gap too small: {poses[i]} vs {poses[j]}"); } (double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose) { var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds; var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom), (box.Right, box.Top), (box.Left, box.Top) }; var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation) - c.Item2 * System.Math.Sin(pose.Rotation)).ToArray(); var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation) + c.Item2 * System.Math.Cos(pose.Rotation)).ToArray(); return (x.Min(), x.Max(), y.Min(), y.Max()); } } }