mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 06:38:48 -04:00
274 lines
11 KiB
C#
274 lines
11 KiB
C#
using OpenNest.Engine.Fill;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Placement;
|
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
|
using OpenNest.Engine.RectanglePacking;
|
|
using OpenNest.Engine.Tests.Jobs;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Tests.RectanglePacking;
|
|
|
|
public class AreaPackerTests
|
|
{
|
|
private const double Eps = 1e-9;
|
|
|
|
private static NestItem Item(string name, double width, double length, int quantity, int priority = 0) =>
|
|
new()
|
|
{
|
|
Drawing = new Drawing(name, TestDrawingFactory.Rectangle(width, length)),
|
|
Quantity = quantity,
|
|
Priority = priority,
|
|
};
|
|
|
|
private static void AssertInsideAndSpaced(IReadOnlyList<Part> parts, Box area, double spacing)
|
|
{
|
|
foreach (var part in parts)
|
|
{
|
|
var b = part.BoundingBox;
|
|
Assert.True(
|
|
b.Left >= area.Left - Eps && b.Bottom >= area.Bottom - Eps
|
|
&& b.Right <= area.Right + Eps && b.Top <= area.Top + Eps,
|
|
$"{part.BaseDrawing.Name} at ({b.Left},{b.Bottom})-({b.Right},{b.Top}) is outside the area"
|
|
);
|
|
}
|
|
|
|
for (var i = 0; i < parts.Count; i++)
|
|
for (var j = i + 1; j < parts.Count; j++)
|
|
{
|
|
var a = parts[i].BoundingBox;
|
|
var b = parts[j].BoundingBox;
|
|
var gapX = System.Math.Max(b.Left - a.Right, a.Left - b.Right);
|
|
var gapY = System.Math.Max(b.Bottom - a.Top, a.Bottom - b.Top);
|
|
Assert.True(
|
|
System.Math.Max(gapX, gapY) >= spacing - Eps,
|
|
$"parts {i} and {j} are {System.Math.Max(gapX, gapY)} apart, spacing is {spacing}"
|
|
);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 0)]
|
|
[InlineData(1, 0)]
|
|
[InlineData(0, 3)]
|
|
[InlineData(1, 3)]
|
|
public void PackArea_AllowsBoundaryOverhangAndValidates(double spacing, double edge)
|
|
{
|
|
var plate = new Plate(new Size(10 + 2 * edge, 10 + 2 * edge))
|
|
{
|
|
PartSpacing = spacing,
|
|
EdgeSpacing = new Spacing(edge, edge),
|
|
};
|
|
var area = plate.WorkArea();
|
|
var items = new List<NestItem> { Item("panel", 10 + SheetPacker.OverhangAllowance * 0.8, 4, 2) };
|
|
|
|
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
|
|
var again = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
|
|
|
|
Assert.Equal(2, parts.Count);
|
|
Assert.Equal(parts.Select(p => (p.Location, p.Rotation)), again.Select(p => (p.Location, p.Rotation)));
|
|
Assert.All(parts, p =>
|
|
{
|
|
Assert.Equal(area.Left, p.BoundingBox.Left, 9);
|
|
Assert.True(p.BoundingBox.Right > area.Right);
|
|
Assert.True(p.BoundingBox.Right <= area.Right + NestTolerances.WorkAreaSlack);
|
|
});
|
|
Assert.True(parts[1].BoundingBox.Bottom - parts[0].BoundingBox.Top >= spacing - Eps);
|
|
var requirements = items.ToDictionary(i => i.Drawing, i => (i.Drawing.Name, i.Quantity));
|
|
Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) }, requirements));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
[InlineData(3)]
|
|
[InlineData(4)]
|
|
public void PackArea_TopOverhangKeepsSpacingInEveryQuadrant(int quadrant)
|
|
{
|
|
var plate = new Plate(new Size(12, 12))
|
|
{
|
|
Quadrant = quadrant,
|
|
EdgeSpacing = new Spacing(1, 1),
|
|
PartSpacing = 1,
|
|
};
|
|
var area = plate.WorkArea();
|
|
var items = new List<NestItem> { Item("panel", 4, 10 + SheetPacker.OverhangAllowance * 0.8, 2) };
|
|
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
|
|
|
|
Assert.Equal(2, parts.Count);
|
|
Assert.All(parts, p =>
|
|
{
|
|
Assert.Equal(area.Bottom, p.BoundingBox.Bottom, 9);
|
|
Assert.True(p.BoundingBox.Top > area.Top);
|
|
Assert.True(p.BoundingBox.Top <= area.Top + NestTolerances.WorkAreaSlack);
|
|
});
|
|
var ordered = parts.OrderBy(p => p.BoundingBox.Left).ToList();
|
|
Assert.True(ordered[1].BoundingBox.Left - ordered[0].BoundingBox.Right >= 1 - Eps);
|
|
Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) },
|
|
items.ToDictionary(i => i.Drawing, i => (i.Drawing.Name, i.Quantity))));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void PackArea_FullAreaDemandCapIncludesSlack(bool rotated)
|
|
{
|
|
var plate = new Plate(new Size(6, 10)) { PartSpacing = 0 };
|
|
var excess = SheetPacker.OverhangAllowance * 0.8;
|
|
var items = new List<NestItem> { rotated ? Item("panel", 6 + excess, 10 + excess, 1)
|
|
: Item("panel", 10 + excess, 6 + excess, 1) };
|
|
|
|
var part = Assert.Single(PlateFillService.PackArea("Default", plate, plate.WorkArea(), items, null, CancellationToken.None));
|
|
|
|
Assert.True(part.BoundingBox.Right <= 10 + NestTolerances.WorkAreaSlack);
|
|
Assert.True(part.BoundingBox.Top <= 6 + NestTolerances.WorkAreaSlack);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
public void PackArea_RejectsBeyondBoundaryAllowance(double spacing)
|
|
{
|
|
var plate = new Plate(new Size(6, 10)) { PartSpacing = spacing };
|
|
var items = new List<NestItem> { Item("panel", 10 + NestTolerances.WorkAreaSlack * 1.1, 4, 1) };
|
|
Assert.Empty(PlateFillService.PackArea("Default", plate, plate.WorkArea(), items, null, CancellationToken.None));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 0, 5, 6, false)]
|
|
[InlineData(5, 0, 5, 6, true)]
|
|
[InlineData(0, 0, 10, 3, false)]
|
|
[InlineData(0, 3, 10, 3, true)]
|
|
public void PackArea_OnlyMatchingPositivePlateEdgesAllowSlack(double x, double y, double w, double h, bool fits)
|
|
{
|
|
var plate = new Plate(new Size(6, 10)) { PartSpacing = 0.5 };
|
|
var area = new Box(x, y, w, h);
|
|
var excess = SheetPacker.OverhangAllowance * 0.8;
|
|
var items = new List<NestItem> { w == 5 ? Item("panel", w + excess, h - 0.25, 1) : Item("panel", 8, h + excess, 1) };
|
|
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
|
|
Assert.Equal(fits ? 1 : 0, parts.Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void MaxRects_InternalFreeEdgesStayStrict(bool horizontal)
|
|
{
|
|
var sheet = new MaxRectsSheet(10, 10, SheetPacker.OverhangAllowance, SheetPacker.OverhangAllowance);
|
|
sheet.Place(horizontal ? new Rect(5, 0, 5, 10) : new Rect(0, 5, 10, 5));
|
|
var excess = SheetPacker.OverhangAllowance * 0.8;
|
|
Assert.Null(sheet.FindBest(horizontal ? 5 + excess : 10, horizontal ? 10 : 5 + excess, FitRule.BottomLeft));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void MaxRects_BoundaryFreeEdgesAllowSlackAfterPlacement(bool horizontal)
|
|
{
|
|
var sheet = new MaxRectsSheet(10, 10, SheetPacker.OverhangAllowance, SheetPacker.OverhangAllowance);
|
|
sheet.Place(horizontal ? new Rect(0, 0, 5, 10) : new Rect(0, 0, 10, 5));
|
|
var excess = SheetPacker.OverhangAllowance * 0.8;
|
|
var fit = sheet.FindBest(horizontal ? 5 + excess : 10, horizontal ? 10 : 5 + excess, FitRule.BottomLeft);
|
|
Assert.NotNull(fit);
|
|
Assert.Equal(horizontal ? new Rect(5, 0, 5, 10) : new Rect(0, 5, 10, 5), fit.Value.Place);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultPackArea_PlacesDemandThatCornerPointPackingMissed()
|
|
{
|
|
// Three 5x7 and two 4x3 boxes fit a 12x12 area exactly when packed by free space;
|
|
// packing only at placed parts' corners left one 4x3 out.
|
|
var plate = new Plate(new Size(12, 12)) { PartSpacing = 0 };
|
|
var area = new Box(0, 0, 12, 12);
|
|
var items = new List<NestItem> { Item("tall", 5, 7, 3), Item("small", 4, 3, 2) };
|
|
|
|
var parts = PlateFillService.PackArea("Default", plate, area, items, null, CancellationToken.None);
|
|
|
|
Assert.Equal(5, parts.Count);
|
|
Assert.Equal(3, parts.Count(p => p.BaseDrawing.Name == "tall"));
|
|
Assert.Equal(2, parts.Count(p => p.BaseDrawing.Name == "small"));
|
|
AssertInsideAndSpaced(parts, area, 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_KeepsSpacingInsideTranslatedArea()
|
|
{
|
|
var area = new Box(10, 20, 30, 20);
|
|
var items = new List<NestItem> { Item("a", 6, 4, 5), Item("b", 4, 3, 4) };
|
|
|
|
var parts = AreaPacker.Pack(area, items, 1, new DefaultFillComparer(), CancellationToken.None);
|
|
|
|
Assert.Equal(9, parts.Count);
|
|
AssertInsideAndSpaced(parts, area, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_TurnsPartThatOnlyFitsRotated()
|
|
{
|
|
// 20 long in X, but the area is only 10 wide in X and 30 tall.
|
|
var area = new Box(0, 0, 10, 30);
|
|
var items = new List<NestItem> { Item("long", 20, 5, 1) };
|
|
|
|
var parts = AreaPacker.Pack(area, items, 0, new DefaultFillComparer(), CancellationToken.None);
|
|
|
|
var part = Assert.Single(parts);
|
|
Assert.Equal(System.Math.PI / 2, part.Rotation, 9);
|
|
AssertInsideAndSpaced(parts, area, 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_ServesLowerPriorityNumberFirst()
|
|
{
|
|
var area = new Box(0, 0, 10, 10);
|
|
var items = new List<NestItem> { Item("later", 10, 10, 1, priority: 1), Item("first", 6, 6, 1, priority: 0) };
|
|
|
|
var parts = AreaPacker.Pack(area, items, 0, new DefaultFillComparer(), CancellationToken.None);
|
|
|
|
Assert.Equal("first", Assert.Single(parts).BaseDrawing.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_NeverExceedsQuantityAndSkipsEmptyDrawings()
|
|
{
|
|
var area = new Box(0, 0, 100, 100);
|
|
var items = new List<NestItem>
|
|
{
|
|
Item("two", 3, 3, 2),
|
|
new() { Drawing = new Drawing("empty", new OpenNest.CNC.Program()), Quantity = 4 },
|
|
};
|
|
|
|
var parts = AreaPacker.Pack(area, items, 0.5, new DefaultFillComparer(), CancellationToken.None);
|
|
|
|
Assert.Equal(2, parts.Count);
|
|
Assert.All(parts, p => Assert.Equal("two", p.BaseDrawing.Name));
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_CancelledTokenStillReturnsFirstValidLayout()
|
|
{
|
|
var area = new Box(0, 0, 30, 20);
|
|
var items = new List<NestItem> { Item("a", 6, 4, 5), Item("b", 4, 3, 4) };
|
|
|
|
var parts = AreaPacker.Pack(area, items, 1, new DefaultFillComparer(), new CancellationToken(canceled: true));
|
|
|
|
Assert.NotEmpty(parts);
|
|
AssertInsideAndSpaced(parts, area, 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pack_LetsStrategyComparerChooseBetweenEqualLayouts()
|
|
{
|
|
// Two 60x2 strips fit stacked or end to end. Both place everything, so the strategy decides:
|
|
// Vertical Remnant keeps the right side clear, Horizontal Remnant keeps the top clear.
|
|
var area = new Box(0, 0, 130, 30);
|
|
var items = new List<NestItem> { Item("a", 60, 2, 1), Item("b", 60, 2, 1) };
|
|
|
|
var vertical = AreaPacker.Pack(area, items, 0, new VerticalRemnantComparer(), CancellationToken.None);
|
|
var horizontal = AreaPacker.Pack(area, items, 0, new HorizontalRemnantComparer(), CancellationToken.None);
|
|
|
|
Assert.Equal(2, vertical.Count);
|
|
Assert.Equal(60, vertical.Max(p => p.BoundingBox.Right) - vertical.Min(p => p.BoundingBox.Left), 9);
|
|
Assert.Equal(2, horizontal.Count);
|
|
Assert.Equal(2, horizontal.Max(p => p.BoundingBox.Top) - horizontal.Min(p => p.BoundingBox.Bottom), 9);
|
|
}
|
|
}
|