using System.Security.Cryptography; using System.Text; using System.Text.Json; using OpenNest.Engine.Jobs; using OpenNest.Engine.NestingEngines.Default; using OpenNest.Engine.NestingEngines.Irregular; using OpenNest.Engine.NestingEngines.Rectangles; using OpenNest.Geometry; using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; using static OpenNest.Engine.Tests.NestingEngines.Shapes; namespace OpenNest.Engine.Tests.NestingEngines; public class IrregularClampZoneTests { private static readonly IReadOnlyDictionary> NoPairs = new Dictionary>(); [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant) { var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant, edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones: [new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]); var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(9, Assert.Single(result.Plates).Placements.Count); AssertDiscsClearClamps(result, 1); LayoutAssert.Valid(job, result); Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05); } [Theory] [InlineData("Irregular")] [InlineData("Rectangles")] [InlineData("Default")] public void BothLanesAndDefaultAvoidClamps(string engine) { var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25), clampZones: [new("front", new Box(0, 0, 3, 0.625))])]); INestingEngine solver = engine switch { "Irregular" => new IrregularNestingEngine(), "Rectangles" => new RectanglesNestingEngine(), _ => new DefaultNestingEngine(), }; var result = solver.Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); AssertDiscsClearClamps(result, 1); LayoutAssert.Valid(job, result); } [Theory] [InlineData(0)] [InlineData(4)] public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing) { var stock = Stock("sheet", 4, 5, spacing: partSpacing, edge: new Spacing(0, 0.25, 0, 0), clampZones: [new("rail", new Box(0, 0, 5, 0.625))]); var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); var pose = Assert.Single(Assert.Single(result.Plates).Placements); Assert.InRange(pose.Y - 1, 0.875, 0.90); AssertDiscsClearClamps(result, 1); LayoutAssert.Valid(job, result); } [Fact] public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement() { var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0), clampZones: [new("rail", new Box(0, 0, 6, 0.625))]); var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Incomplete, result.Status); Assert.Empty(result.Plates); Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced); LayoutAssert.Valid(job, result); } [Theory] [InlineData(0)] [InlineData(1)] public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis) { var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0), clampZones: [new("rail", new Box(0, 0, 10, 1))]); var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]); var types = PartCatalog.Build(job); var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!; var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 }; var pairs = new Dictionary> { [0] = new[] { pair } }; var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock, (PackAxis)axis, 1, new WorkCounter()).Fill([2], default); Assert.Equal(2, fill.Parts.Count); Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25)); if (axis == 0) Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation)); AssertValidFill(job, fill); } [Fact] public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace() { var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0), clampZones: [new("rail", new Box(0, 0, 12, 1))]); var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]); var types = PartCatalog.Build(job); using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue); var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock, PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default); Assert.Equal(6, fill.Parts.Count); Assert.True(blocks.PreparationCount > 0); Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation)); Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25)); AssertValidFill(job, fill); } [Fact] public void SameSpacingStocksDoNotShareClampGeometry() { var wall = new ClampZone("rail", new Box(0, 0, 6, 1)); var job = Job([Rectangle("part", 2, 2, 1)], [Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal("open", Assert.Single(result.Plates).StockId); LayoutAssert.Valid(job, result); } [Fact] public void ConcavePartCanUseItsOpenNotchAroundAClamp() { // The only fitting pose surrounds the rail in an open notch. Blocking the // part's bounding box instead of its NFP would incorrectly reject this job. var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3), (3, 0), (4, 0), (4, 4), (0, 4)); var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0), clampZones: [new("notch", new Box(1.5, 0, 1, 2))]); var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); var pose = Assert.Single(Assert.Single(result.Plates).Placements); Assert.Equal(0, pose.X, 8); Assert.Equal(0.1, pose.Y, 8); // Analytic notch walls versus the rectangle inflated by 0.1. Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1); LayoutAssert.Valid(job, result); } [Fact] public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles() { var job = Job([Rectangle("part", 2, 2, 2)], [Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]), Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]); var result = new IrregularNestingEngine().Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(2, result.Plates.Count); foreach (var sheet in result.Plates) { var pose = Assert.Single(sheet.Placements); Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3); } LayoutAssert.Valid(job, result); } [Fact] public void CancelledClampPackingDoesNotReturnAnEmptySuccess() { var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]); var job = Job([Rectangle("part", 2, 2, 1)], [stock]); var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs, stock, PackAxis.X, 1, new WorkCounter()); Assert.Throws(() => packer.Fill([1], new CancellationToken(true))); } [Fact] public void RemoteSubGridClampDoesNotCrashWholeSolve() { // Exact reported reproducer: Stock takes Y width, then X length. var stock = Stock("sheet", 10, 12, clampZones: [new("remote-small-clamp", new Box(8, 0, 1e-5, 1e-5))]); var job = Job([Rectangle("part", 2, 2, 1)], [stock]); NestJobValidator.Validate(job); var result = new IrregularNestingEngine().Solve(job); AssertCompleteRectangleClearsClamps(result, 0, 0); } public static IEnumerable SubGridClampCases() { foreach (var quadrant in Enumerable.Range(1, 4)) foreach (var fixture in Enumerable.Range(0, 6)) yield return [quadrant, fixture]; } [Theory] [MemberData(nameof(SubGridClampCases))] public void SubGridClampsBlockTheOriginalRectanglePlacement(int quadrant, int fixture) { var (bounds, originX, originY, clearance) = fixture switch { 0 => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 0.0), 1 => (new Box(0.75, 0.5, 1e-5, 1e-5), 7.25, -3.125, 0.0), 2 => (new Box(0.75, 0.5, 1e-5, 0.75), 7.25, -3.125, 0.0), 3 => (new Box(0.5, 0.75, 0.75, 1e-5), 7.25, -3.125, 0.0), // Positive dimensions can disappear even before grid conversion. 4 => (new Box(1, 1, 1e-20, 1e-20), 7.25, -3.125, 0.0), _ => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 1e-6), }; var edge = new Spacing(0, clearance, 0, 0); var stock = Stock("sheet", 10, 12, quadrant: quadrant, edge: edge, clampZones: [new("tiny", bounds)]); var part = Rectangle("part", 2, 2, 1, x: originX, y: originY); var job = Job([part], [stock]); NestJobValidator.Validate(job); var zone = Assert.Single(stock.ClampZones); if (fixture == 4) { Assert.Equal(zone.Bounds.Left, zone.Bounds.Right); Assert.Equal(zone.Bounds.Bottom, zone.Bounds.Top); } // A drop-clamp mutation must fail: the same job without a clamp enters it. var unclamped = Job([part], [Stock("sheet", 10, 12, quadrant: quadrant, edge: edge)]); var original = new IrregularNestingEngine().Solve(unclamped); AssertCompleteRectangleClearsClamps(original, originX, originY); var originalPose = Assert.Single(Assert.Single(original.Plates).Placements); Assert.True(RectangleEntersInflatedClamp(originalPose, originX, originY, zone.Bounds, clearance), "The fixture must block the unclamped placement."); var result = new IrregularNestingEngine().Solve(job); AssertCompleteRectangleClearsClamps(result, originX, originY); } [Theory] [InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")] [InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")] [InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")] // Full result hashes captured on d71f086 before static-obstacle preparation. public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected) { var job = fixture switch { 0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]), 1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)], [Stock("sheet", 10, 15, spacing: 0.25)]), _ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]), }; var result = new IrregularNestingEngine().Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result)))); Assert.Equal(expected, hash); } private static void AssertCompleteRectangleClearsClamps(NestJobResult result, double originX, double originY) { // Do not call NestLayoutCheck here: its separate S1 tiny-clamp null-outline // defect can throw when broad-phase bounds touch. This is an analytic oracle. Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStopReason.Completed, result.StopReason); Assert.Equal(new PartFulfillment("part", 1, 1, 0), Assert.Single(result.Fulfillment)); Assert.Equal(1, Assert.Single(result.StockUsage).Used); var sheet = Assert.Single(result.Plates); var pose = Assert.Single(sheet.Placements); Assert.Equal("part", pose.PartId); Assert.Equal(0, pose.InstanceIndex); Assert.Equal(0, pose.Rotation); var work = sheet.Stock.WorkArea; var slack = NestTolerances.WorkAreaSlack; var left = pose.X + originX; var bottom = pose.Y + originY; Assert.True(left >= work.Left - slack && bottom >= work.Bottom - slack && left + 2 <= work.Right + slack && bottom + 2 <= work.Top + slack); foreach (var zone in sheet.Stock.ClampZones) Assert.False(RectangleEntersInflatedClamp(pose, originX, originY, zone.Bounds, sheet.Stock.EdgeSpacing.Bottom), $"Rectangle at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}"); } private static bool RectangleEntersInflatedClamp(NestJobPlacement pose, double originX, double originY, Box bounds, double clearance) { // Decimal arithmetic retains the positive 1e-20 fixture dimensions even // when Box.Right == Box.Left in double arithmetic. No Clipper/checker reuse. var left = (decimal)pose.X + (decimal)originX; var bottom = (decimal)pose.Y + (decimal)originY; var c = (decimal)clearance; return left < (decimal)bounds.Left + (decimal)bounds.Length + c && left + 2 > (decimal)bounds.Left - c && bottom < (decimal)bounds.Bottom + (decimal)bounds.Width + c && bottom + 2 > (decimal)bounds.Bottom - c; } private static void AssertDiscsClearClamps(NestJobResult result, double radius) { foreach (var sheet in result.Plates) foreach (var pose in sheet.Placements) foreach (var zone in sheet.Stock.ClampZones) { var b = zone.Bounds; var c = sheet.Stock.EdgeSpacing.Bottom; var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c); var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c); Assert.True(dx * dx + dy * dy >= radius * radius, $"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}"); } } private static void AssertValidFill(NestJob job, SheetFill fill) { var builder = new NestJobResultBuilder(job); builder.AddSheet(fill.Stock, fill.Parts.Select(p => (job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation))); LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed)); } }