Files
OpenNest/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

340 lines
15 KiB
C#

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<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[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<int, IReadOnlyList<PairPose>> { [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 CanceledClampPackingDoesNotReturnAnEmptySuccess()
{
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<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")]
[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));
}
}