diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs index 1d1597f..16b1ddd 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs @@ -147,4 +147,159 @@ public class RectanglesClampZoneTests LayoutAssert.Valid(jobClamped, clamped); Assert.Equal(LayoutHash(plain), LayoutHash(clamped)); } + + // ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) -------- + // + // The packer reserves a trailing part-spacing tail behind every item, so the block it + // carves per axis is [zone - clearance + s, zone + clearance]. When s exceeds the + // inflated span, that interval inverts. D11: block a zero-extent line at the clamp + // edge facing the sheet interior — at (zone max + clearance) when the zone center lies + // in the lower half of the bin on that axis, else at (zone min - clearance + s). The + // old collapse to the zone centerline let a part start inside the clamp. + // + // The oracle below is independent of the packer and of NestLayoutCheck: material + // [x, x+w] x [y, y+h] must not share interior with the clamp footprint predicted + // directly from the rail rule (center +/- width/2 from the origin edge, depth into the + // sheet, translated exactly like the work area), inflated by the bottom-edge spacing. + // Boundary contact is clear (C16). + + private static Box InflatedRail(int quadrant, double length, double width, + double center, double clampWidth, double depth, double clearance) + { + var left = quadrant is 1 or 4 ? 0.0 : -length; + var bottom = quadrant is 1 or 2 ? 0.0 : -width; + var near = center - clampWidth / 2 + left; + var far = center + clampWidth / 2 + left; + return new Box( + System.Math.Min(near, far) - clearance, + bottom - clearance, + clampWidth + 2 * clearance, + depth + 2 * clearance); + } + + private static void AssertMaterialClearOfRail(NestJobResult result, Box inflated, + double partW, double partH) + { + Assert.NotEmpty(result.Plates.SelectMany(p => p.Placements)); + foreach (var p in result.Plates.SelectMany(pl => pl.Placements)) + { + var overlaps = p.X < inflated.Right - 1e-9 && p.X + partW > inflated.Left + 1e-9 + && p.Y < inflated.Top - 1e-9 && p.Y + partH > inflated.Bottom + 1e-9; + Assert.False(overlaps, + $"material [{p.X},{p.X + partW}] x [{p.Y},{p.Y + partH}] intrudes into the " + + $"inflated clamp footprint [{inflated.Left},{inflated.Right}] x " + + $"[{inflated.Bottom},{inflated.Top}]"); + } + } + + [Theory] + // PM d86d22c9 probe fixture: Y degenerate (s=4 > 0.625 + 2x0.25), full-width front + // rail. Red on ab50900: Rectangles completed at y=0.3125, material on the clamp. + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void DegenerateYSpanKeepsExactClearanceInEveryQuadrant(int quadrant) + { + var stock = Stock("sheet", 4, 5, spacing: 4, edge: new Spacing(0, 0.25, 0, 0), + quadrant: quadrant, clampZones: [new ClampZone("rail", new Box(0, 0, 5, 0.625))]); + var job = Job(new[] { Rectangle("square", 2, 2, 1) }, new[] { stock }); + + var result = Engine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + AssertMaterialClearOfRail(result, + InflatedRail(quadrant, 5, 4, 2.5, 5, 0.625, 0.25), 2, 2); + } + + [Fact] + public void DegenerateXSpanMidRailKeepsExactClearance() + { + // X degenerate (s=3 > 1.5 + 2x0.25); the 10-wide part is forced across the clamp's + // X span because neither side can hold it with the tail. Red on ab50900: completed + // at x=11.5 (the centerline), material inside the clamp. + var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]); + var job = Job(new[] { Rectangle("wide", 10, 2, 1) }, new[] { stock }); + + var result = Engine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 11.5, 1.5, 0.625, 0.25), 10, 2); + } + + [Fact] + public void DegenerateXSpanFarRailClearsTheInteriorFacingEdge() + { + // The clamp center sits in the upper half of the bin, so D11 blocks the + // origin-facing edge (zone min - clearance + s = 20). Red on ab50900: the + // centerline split let the 2x2 land at x=21, material inside the clamp; + // after D11 it lands at x=20 — boundary contact, which is clear. + var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new ClampZone("far-rail", new Box(20.25, 0, 1.5, 0.625))]); + var job = Job(new[] { Rectangle("small", 2, 2, 1) }, new[] { stock }); + + var result = Engine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 21, 1.5, 0.625, 0.25), 2, 2); + } + + // ---- C15 keep rows: non-inverted spans and between-clamp packing stay bit-exact ---- + + private static NestJobResult KeepFixtureLargeSpacingNonInverted() => Engine().Solve(Job( + new[] { Rectangle("blk", 6, 6, 6) }, + new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625))]) })); + + private static NestJobResult KeepFixtureBetweenClamps() => Engine().Solve(Job( + new[] { Rectangle("row", 2, 2, 3) }, + new[] { Stock("strip", 2, 12, clampZones: new[] + { + new ClampZone("clamp-a", new Box(3, 0, 2, 1)), + new ClampZone("clamp-b", new Box(7, 0, 2, 1)), + }) })); + + private static NestJobResult KeepFixtureBetweenClampsWithSpacing() => Engine().Solve(Job( + new[] { Rectangle("row", 2, 2, 3) }, + new[] { Stock("strip", 2, 12, spacing: 0.5, clampZones: new[] + { + new ClampZone("clamp-a", new Box(3, 0, 2, 1)), + new ClampZone("clamp-b", new Box(7, 0, 2, 1)), + }) })); + + private static NestJobResult KeepFixtureMidRailSmallSpacing() => Engine().Solve(Job( + new[] { Rectangle("wide", 10, 2, 2) }, + new[] { Stock("sheet", 4, 23, spacing: 0.5, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]) })); + + [Theory] + // Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence): + // non-inverted clamp spans must keep today's blocks and layouts bit-for-bit. + [InlineData("KEEP_LARGE_SPACING")] + [InlineData("KEEP_BETWEEN_CLAMPS")] + [InlineData("KEEP_BETWEEN_SPACED")] + [InlineData("KEEP_MID_RAIL_SMALL_SPACING")] + public void NonInvertedClampLayoutsMatchAb50900Digests(string fixture) + { + var hash = fixture switch + { + "KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450", + "KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61", + "KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61", + "KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71", + _ => throw new ArgumentOutOfRangeException(nameof(fixture)), + }; + var actual = fixture switch + { + "KEEP_LARGE_SPACING" => LayoutHash(KeepFixtureLargeSpacingNonInverted()), + "KEEP_BETWEEN_CLAMPS" => LayoutHash(KeepFixtureBetweenClamps()), + "KEEP_BETWEEN_SPACED" => LayoutHash(KeepFixtureBetweenClampsWithSpacing()), + "KEEP_MID_RAIL_SMALL_SPACING" => LayoutHash(KeepFixtureMidRailSmallSpacing()), + _ => throw new ArgumentOutOfRangeException(nameof(fixture)), + }; + Assert.Equal(hash, actual); + } } diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs index 9126a4e..51f3b82 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -90,9 +90,14 @@ internal static class SheetPacker /// inflated-box model an item at position p covers material [p, p + size] plus a trailing /// part-spacing tail; blocking bin-local [zone - clearance + s, zone + clearance] per axis /// therefore means exactly: item material keeps at least the bottom-edge clearance from - /// every clamp side, without paying any extra part-spacing tax there. A span that - /// degenerates because the spacing exceeds the inflated zone collapses to the zone - /// centerline - conservative (it forbids crossing the clamp), never permissive. + /// every clamp side, without paying any extra part-spacing tax there. When s exceeds the + /// inflated span the interval inverts (decision D11): block a zero-extent line at the + /// clamp edge facing the sheet interior — at (zone max + clearance) when the zone center + /// lies in the lower half of the bin on that axis, else at (zone min - clearance + s). + /// Parts on the interior side then keep the exact clearance; the unavoidable extra + /// exclusion (s - extent - 2*clearance) lands on the side toward the nearer sheet edge. + /// The old collapse to the zone centerline was NOT conservative: an item could start + /// exactly on the centerline with material inside the clamp. /// Empty zone lists change nothing: the free list evolves bit-identically (C2). /// private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s) @@ -110,17 +115,24 @@ internal static class SheetPacker var right = x0(b.Right) + clearance; if (right <= left) { - var centerX = x0((b.Left + b.Right) * 0.5); - left = centerX; - right = centerX; + var center = x0((b.Left + b.Right) * 0.5); + // D11: the interior-facing clamp edge (toward the farther sheet edge). + var line = center < sheet.Width * 0.5 + ? x0(b.Right) + clearance + : x0(b.Left) - clearance + s; + left = line; + right = line; } var bottom = y0(b.Bottom) - clearance + s; var top = y0(b.Top) + clearance; if (top <= bottom) { - var centerY = y0((b.Bottom + b.Top) * 0.5); - bottom = centerY; - top = centerY; + var center = y0((b.Bottom + b.Top) * 0.5); + var line = center < sheet.Height * 0.5 + ? y0(b.Top) + clearance + : y0(b.Bottom) - clearance + s; + bottom = line; + top = line; } sheet.Block(new Rect(left, bottom, right - left, top - bottom)); }