From 6b6928213d54bb28825e260ea6cc025be8185b1e Mon Sep 17 00:00:00 2001 From: ajisaacs Date: Thu, 1 Oct 2026 08:15:57 -0400 Subject: [PATCH] fix(packing): apply work-area slack only at plate boundaries --- .../RectanglePacking/AreaPackerTests.cs | 128 ++++++++++++++++++ .../Placement/Fillers/DefaultPlateFiller.cs | 2 +- .../RectanglePacking/AreaPacker.cs | 15 +- .../RectanglePacking/MaxRectsSheet.cs | 16 ++- docs/nesting-engines.md | 5 + 5 files changed, 159 insertions(+), 7 deletions(-) diff --git a/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs index 94e71e8..d9e3028 100644 --- a/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs +++ b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs @@ -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 { 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 { 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 { 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 { 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 { 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() { diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs index 52540fa..1171c56 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs @@ -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) diff --git a/OpenNest.Engine/RectanglePacking/AreaPacker.cs b/OpenNest.Engine/RectanglePacking/AreaPacker.cs index 2b5a4c2..51b9b93 100644 --- a/OpenNest.Engine/RectanglePacking/AreaPacker.cs +++ b/OpenNest.Engine/RectanglePacking/AreaPacker.cs @@ -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. /// + /// Plate work area, when known. Only matching right/top boundaries + /// receive the existing work-area overhang allowance; internal areas remain strict. public static List Pack( - Box area, IReadOnlyList items, double spacing, IFillComparer comparer, CancellationToken token) + Box area, IReadOnlyList items, double spacing, IFillComparer comparer, CancellationToken token, Box? workArea = null) { var boxes = items.Select(i => i.Drawing.Program.BoundingBox()).ToList(); var types = new List(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 diff --git a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs index 6089f07..af788de 100644 --- a/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs +++ b/OpenNest.Engine/RectanglePacking/MaxRectsSheet.cs @@ -50,9 +50,13 @@ internal sealed class MaxRectsSheet private readonly List free = new(); private readonly List 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)) diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 41f0a68..d6738a3 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -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