fix(packing): apply work-area slack only at plate boundaries

This commit is contained in:
aj
2026-10-01 08:15:57 -04:00
parent a26e663f01
commit 6b6928213d
5 changed files with 159 additions and 7 deletions
@@ -1,5 +1,7 @@
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;
@@ -44,6 +46,132 @@ public class AreaPackerTests
}
}
[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()
{
@@ -399,7 +399,7 @@ internal class DefaultPlateFiller : PlateFillerBase
CancellationToken token
)
{
return AreaPacker.Pack(box, items, Plate.PartSpacing, Comparer, token);
return AreaPacker.Pack(box, items, Plate.PartSpacing, Comparer, token, Plate.WorkArea());
}
protected virtual void RunPipeline(FillContext context)
+12 -3
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -33,13 +34,21 @@ internal static class AreaPacker
/// right/top sides, so neighbouring parts are one spacing apart and the last may touch the
/// area's edge. A cancelled token stops between candidates and keeps the best layout so far.
/// </summary>
/// <param name="workArea">Plate work area, when known. Only matching right/top boundaries
/// receive the existing work-area overhang allowance; internal areas remain strict.</param>
public static List<Part> Pack(
Box area, IReadOnlyList<NestItem> items, double spacing, IFillComparer comparer, CancellationToken token)
Box area, IReadOnlyList<NestItem> items, double spacing, IFillComparer comparer, CancellationToken token, Box? workArea = null)
{
var boxes = items.Select(i => i.Drawing.Program.BoundingBox()).ToList();
var types = new List<PackType>(items.Count);
var demand = new int[items.Count];
var packArea = (area.Length + spacing) * (area.Width + spacing);
// Only the positive edges can overhang: placements anchor at a free box's lower-left.
var rightSlack = workArea != null && System.Math.Abs(area.Right - workArea.Right) <= MaxRectsSheet.Eps
? SheetPacker.OverhangAllowance : 0;
var topSlack = workArea != null && System.Math.Abs(area.Top - workArea.Top) <= MaxRectsSheet.Eps
? SheetPacker.OverhangAllowance : 0;
// Include boundary slack in this upper bound so a near-full-area item is not capped at zero.
var packArea = (area.Length + spacing + rightSlack) * (area.Width + spacing + topSlack);
for (var i = 0; i < items.Count; i++)
{
var w = boxes[i].Length + spacing;
@@ -67,7 +76,7 @@ internal static class AreaPacker
if (best != null && token.IsCancellationRequested)
return best;
var sheet = new MaxRectsSheet(area.Length + spacing, area.Width + spacing);
var sheet = new MaxRectsSheet(area.Length + spacing, area.Width + spacing, rightSlack, topSlack);
var placed = MaxRectsPacker.Pack(
types, (int[])demand.Clone(), sheet, rule, mode, CancellationToken.None);
var tierScores = tiers
@@ -50,9 +50,13 @@ internal sealed class MaxRectsSheet
private readonly List<Rect> free = new();
private readonly List<Rect> used = new();
private readonly double rightSlack;
private readonly double topSlack;
public MaxRectsSheet(double width, double height)
public MaxRectsSheet(double width, double height, double rightSlack = 0, double topSlack = 0)
{
this.rightSlack = rightSlack;
this.topSlack = topSlack;
Width = width;
Height = height;
free.Add(new Rect(0, 0, width, height));
@@ -68,9 +72,15 @@ internal sealed class MaxRectsSheet
(Rect Place, double Primary, double Secondary)? best = null;
foreach (var f in free)
{
if (w > f.W + Eps || h > f.H + Eps)
// Clamp only at the bin's allowed plate boundaries. Internal free edges still
// use Eps, and real sizes are retained there to preserve neighbour spacing.
var packW = w > f.W && w <= f.W + rightSlack && System.Math.Abs(f.Right - Width) <= Eps
? f.W : w;
var packH = h > f.H && h <= f.H + topSlack && System.Math.Abs(f.Top - Height) <= Eps
? f.H : h;
if (packW > f.W + Eps || packH > f.H + Eps)
continue;
var place = new Rect(f.X, f.Y, w, h);
var place = new Rect(f.X, f.Y, packW, packH);
var (p, s) = Score(f, place, rule);
if (best is not { } b || p < b.Primary - Eps
|| (p <= b.Primary + Eps && s < b.Secondary - Eps))
+5
View File
@@ -20,6 +20,11 @@ Rectangles places irregular parts validly, but only as their bounding boxes; it
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
valid at box contact.
Interactive/full-area box packing uses the same 90% work-area slack allowance as Rectangles.
A free box may absorb a slightly oversized side only at its right/top edge when that edge
coincides with the plate work-area boundary. Internal leftover edges keep the strict packing
tolerance, and actual part dimensions still determine spacing away from the plate boundary.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. Concave no-fit polygons are prepared with a single boundary/containment union.
Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden