fix(irregular): retain sub-grid clamp obstacles

This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent c374e02614
commit fbca54637b
3 files changed
+123 -6

No files matched your search

@@ -193,6 +193,67 @@ public class IrregularClampZoneTests
Assert.Throws<OperationCanceledException>(() => 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<object[]> 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)
@@ -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
+10 -2
View File
@@ -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