mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 18:01:37 -04:00
340 lines
15 KiB
C#
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 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<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));
|
|
}
|
|
}
|