mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-01 22:28:49 -04:00
fix(packing): apply work-area slack only at plate boundaries
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user