diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs index 3107ec4..ad2e2e8 100644 --- a/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs @@ -193,6 +193,67 @@ public class IrregularClampZoneTests 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")] @@ -214,6 +275,45 @@ public class IrregularClampZoneTests 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) diff --git a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs index 54bfb28..1ceb7b0 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs @@ -21,10 +21,8 @@ internal sealed class ClampObstacles for (var i = 0; i < rectangles.Length; i++) { var box = stock.ClampZones[i].Bounds; - var left = box.Left - clearance; - var bottom = box.Bottom - clearance; - var right = box.Right + clearance; - var top = box.Top + clearance; + var (left, right) = EncloseOnGrid(box.Left - clearance, box.Right + clearance); + var (bottom, top) = EncloseOnGrid(box.Bottom - clearance, box.Top + clearance); rectangles[i] = new Orientation { // Real part types are nonnegative; the private cache cannot alias them. @@ -44,6 +42,17 @@ internal sealed class ClampObstacles } } + // NoFitCache quantizes before adding its safety margin. Enclose the keep-out + // on that existing lattice so a sub-grid rectangle cannot disappear first. + private static (double Lower, double Upper) EncloseOnGrid(double lower, double upper) + { + var scale = System.Math.Pow(10, NoFitCache.Precision); + var first = System.Math.Floor(lower * scale); + // Positive dimensions can be smaller than a coordinate ulp (upper == lower). + var last = System.Math.Max(System.Math.Ceiling(upper * scale), first + 1); + return (first / scale, last / scale); + } + public int Count => rectangles.Length; // No additional physical spacing. NoFitCache retains the established arc/grid safety diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 8a432ce..3ae9306 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -79,8 +79,16 @@ per stock per solve, separately from the part-spacing cache. Every orientation's free region excludes these obstacles, including extra orientations introduced by pairs or Fill blocks. Blocks still size their proposals from work-area/placed-part rectangles; member free regions determine whether the whole proposal can actually fit around clamps. -Open notches and material between clamps remain available. Existing arc/grid safety -margins, covered-hole certification and cancellation apply without relaxed tolerances. +Open notches and material between clamps remain available. Inflated synthetic rectangles +are enclosed on the existing clipping grid before NFP preparation, retaining at least one +grid interval per axis so positive sub-grid clamps cannot disappear. Existing arc/grid +safety margins, covered-hole certification and cancellation apply without relaxed tolerances. + +Known clamp edge cases remain in the earlier shared/Rectangles implementation: +large part spacing can make Rectangles overlap a clamp; rectangles below the clipping +grid can make layout validation (including Default's candidate scoring) throw. Irregular's +static-obstacle preparation does not repair these separate paths. The ordinary clamp +fixtures pass for both lanes and Default; this is not an all-input clamp-safety guarantee. This is whole-job engine support, not interactive fill/push, desktop clamp editing or cutoff/post-verification support; those consumers are separate work. Other engines and