diff --git a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs index a7024e6..a47d39a 100644 --- a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs +++ b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs @@ -1,8 +1,8 @@ using System.Globalization; using OpenNest.CNC; +using OpenNest.Engine.Jobs; using OpenNest.Geometry; using Xunit; -using OpenNest.Engine.Jobs; namespace OpenNest.Engine.Tests.Jobs; @@ -16,7 +16,9 @@ namespace OpenNest.Engine.Tests.Jobs; /// /// /// Captured on Linux/.NET 8 at commit 42bbde7 (post ShrinkFiller axis fix), verified -/// identical across 30 repeat runs per strategy. The Strip strategy is only pinned on the +/// identical across 30 repeat runs per strategy. The Default and remnant mixed-job layouts were +/// re-captured when leftover packing moved to the maximal-rectangles packer (every pose passes +/// NestLayoutCheck). The Strip strategy is only pinned on the /// rectangle-variety job: on dense mixed-shape jobs the iterative shrink path intermittently /// proposes overlapping candidates (pre-existing scheduling nondeterminism, not a regression), /// so its mixed-geometry layout is deliberately not pinned here. @@ -169,12 +171,12 @@ public class GoldenLayoutTests AssertGolden( result, [ - P("arc", 0, 15, 1, 0), - P("arc", 1, 15, 7, 0), - P("arc", 2, 15, 13, 0), - P("lshape", 0, 15, 19, 0), - P("lshape", 1, 15, 24, 0), - P("lshape", 2, 21.5, 1, 0), + P("arc", 0, 20, 1, 1.5707963267948966), + P("arc", 1, 20, 7.5, 1.5707963267948966), + P("arc", 2, 20, 14, 1.5707963267948966), + P("lshape", 0, 19, 20.5, 1.5707963267948966), + P("lshape", 1, 24, 20.5, 1.5707963267948966), + P("lshape", 2, 25, 1, 1.5707963267948966), P("rect-a", 0, 5, 21, 1.5707963267948966), P("rect-a", 1, 1, 1, 0), P("rect-a", 2, 1, 6, 0), @@ -183,12 +185,12 @@ public class GoldenLayoutTests P("rect-a", 5, 10, 21, 1.5707963267948966), P("rect-a", 6, 8, 1, 0), P("rect-a", 7, 8, 6, 0), - P("rect-b", 0, 21.5, 6, 0), - P("rect-b", 1, 21.5, 10, 0), - P("rect-b", 2, 21.5, 14, 0), - P("rect-b", 3, 22, 19, 0), - P("rect-b", 4, 22, 23, 0), - P("rect-b", 5, 26.5, 6, 0), + P("rect-b", 0, 24, 8, 1.5707963267948966), + P("rect-b", 1, 24, 13, 1.5707963267948966), + P("rect-b", 2, 28, 8, 1.5707963267948966), + P("rect-b", 3, 28, 13, 1.5707963267948966), + P("rect-b", 4, 28, 18, 1.5707963267948966), + P("rect-b", 5, 28, 23, 1.5707963267948966), ] ); } @@ -206,12 +208,12 @@ public class GoldenLayoutTests AssertGolden( result, [ - P("arc", 0, 15, 1, 0), - P("arc", 1, 15, 7, 0), - P("arc", 2, 15, 13, 0), - P("lshape", 0, 15, 19, 0), - P("lshape", 1, 15, 24, 0), - P("lshape", 2, 21.5, 1, 0), + P("arc", 0, 20, 1, 1.5707963267948966), + P("arc", 1, 20, 7.5, 1.5707963267948966), + P("arc", 2, 20, 14, 1.5707963267948966), + P("lshape", 0, 19, 20.5, 1.5707963267948966), + P("lshape", 1, 24, 20.5, 1.5707963267948966), + P("lshape", 2, 25, 1, 1.5707963267948966), P("rect-a", 0, 5, 21, 1.5707963267948966), P("rect-a", 1, 1, 1, 0), P("rect-a", 2, 1, 6, 0), @@ -220,12 +222,12 @@ public class GoldenLayoutTests P("rect-a", 5, 10, 21, 1.5707963267948966), P("rect-a", 6, 8, 1, 0), P("rect-a", 7, 8, 6, 0), - P("rect-b", 0, 21.5, 6, 0), - P("rect-b", 1, 21.5, 10, 0), - P("rect-b", 2, 21.5, 14, 0), - P("rect-b", 3, 22, 19, 0), - P("rect-b", 4, 22, 23, 0), - P("rect-b", 5, 26.5, 6, 0), + P("rect-b", 0, 24, 8, 1.5707963267948966), + P("rect-b", 1, 24, 13, 1.5707963267948966), + P("rect-b", 2, 28, 8, 1.5707963267948966), + P("rect-b", 3, 28, 13, 1.5707963267948966), + P("rect-b", 4, 28, 18, 1.5707963267948966), + P("rect-b", 5, 28, 23, 1.5707963267948966), ] ); } @@ -241,12 +243,12 @@ public class GoldenLayoutTests AssertGolden( result, [ - P("arc", 0, 1, 23, 0), - P("arc", 1, 7.5, 23, 0), - P("arc", 2, 8, 8, 0), - P("lshape", 0, 1, 8, 0), - P("lshape", 1, 1, 13, 0), - P("lshape", 2, 1, 18, 0), + P("arc", 0, 1, 8, 0), + P("arc", 1, 7.5, 8, 0), + P("arc", 2, 14, 8, 0), + P("lshape", 0, 20.5, 8, 0), + P("lshape", 1, 27.5, 8, 0), + P("lshape", 2, 34.5, 8, 0), P("rect-a", 0, 1, 1, 0), P("rect-a", 1, 8, 1, 0), P("rect-a", 2, 15, 1, 0), @@ -255,12 +257,12 @@ public class GoldenLayoutTests P("rect-a", 5, 36, 1, 1.5707963267948966), P("rect-a", 6, 41, 1, 1.5707963267948966), P("rect-a", 7, 46, 1, 1.5707963267948966), - P("rect-b", 0, 8, 14, 0), - P("rect-b", 1, 8, 18, 0), - P("rect-b", 2, 13, 14, 0), - P("rect-b", 3, 13, 18, 0), - P("rect-b", 4, 14, 23, 0), - P("rect-b", 5, 14.5, 8, 0), + P("rect-b", 0, 41.5, 8, 0), + P("rect-b", 1, 41.5, 12, 0), + P("rect-b", 2, 20.5, 13, 0), + P("rect-b", 3, 25.5, 13, 0), + P("rect-b", 4, 30.5, 13, 0), + P("rect-b", 5, 35.5, 13, 0), ] ); } diff --git a/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs new file mode 100644 index 0000000..94e71e8 --- /dev/null +++ b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs @@ -0,0 +1,145 @@ +using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement; +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 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}" + ); + } + } + + [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 { 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 { 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 { 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 { 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 + { + 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 { 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 { 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); + } +} diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs index d7c8fad..52540fa 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs @@ -399,13 +399,7 @@ internal class DefaultPlateFiller : PlateFillerBase CancellationToken token ) { - var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); - var bin = BinConverter.CreateBin(box, Plate.PartSpacing); - - var engine = new PackBottomLeft(bin); - engine.Pack(binItems); - - return BinConverter.ToParts(bin, items); + return AreaPacker.Pack(box, items, Plate.PartSpacing, Comparer, token); } protected virtual void RunPipeline(FillContext context) diff --git a/OpenNest.Engine/RectanglePacking/AreaPacker.cs b/OpenNest.Engine/RectanglePacking/AreaPacker.cs new file mode 100644 index 0000000..2b5a4c2 --- /dev/null +++ b/OpenNest.Engine/RectanglePacking/AreaPacker.cs @@ -0,0 +1,124 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.RectanglePacking; + +/// +/// Packs mixed parts into one rectangular area as their program bounding boxes, at 0 or 90 +/// degrees, with the maximal-rectangles packer. Every fit rule and pick mode is tried. The layout +/// that places the most parts, then the most box area, wins, compared tier by tier from the lowest +/// ; the caller's fill comparer breaks ties, so leftover packing +/// keeps the same goal (density, or a clear right or top remnant) as the rest of the fill. +/// +internal static class AreaPacker +{ + private static readonly FitRule[] Rules = + { + FitRule.BestShortSide, + FitRule.BestLongSide, + FitRule.BestArea, + FitRule.BottomLeft, + FitRule.LeftBottom, + FitRule.ContactPoint, + }; + + private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered }; + + /// + /// Returns the packed parts. Items and the area grow by on their + /// 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. + /// + public static List Pack( + Box area, IReadOnlyList items, double spacing, IFillComparer comparer, CancellationToken token) + { + 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); + for (var i = 0; i < items.Count; i++) + { + var w = boxes[i].Length + spacing; + var h = boxes[i].Width + spacing; + var sizes = new List<(double, double)>(); + if (w > 0 && h > 0 && boxes[i].Length > 0 && boxes[i].Width > 0) + { + sizes.Add((w, h)); + if (!w.IsEqualTo(h)) + sizes.Add((h, w)); + } + types.Add(new PackType(items[i].Priority, sizes, boxes[i].Length * boxes[i].Width)); + // More copies than the area could hold by box area alone can never be placed. + var capacity = sizes.Count > 0 ? (int)System.Math.Min(int.MaxValue, packArea / (w * h)) : 0; + demand[i] = System.Math.Clamp(items[i].Quantity, 0, capacity); + } + + var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray(); + List? best = null; + (int Count, double Area)[]? bestTiers = null; + + foreach (var mode in Modes) + foreach (var rule in Rules) + { + if (best != null && token.IsCancellationRequested) + return best; + + var sheet = new MaxRectsSheet(area.Length + spacing, area.Width + spacing); + var placed = MaxRectsPacker.Pack( + types, (int[])demand.Clone(), sheet, rule, mode, CancellationToken.None); + var tierScores = tiers + .Select(tier => + { + var inTier = placed.Where(p => types[p.Type].Priority == tier).ToList(); + return (inTier.Count, inTier.Sum(p => types[p.Type].Area)); + }) + .ToArray(); + var compare = best == null ? 1 : CompareTiers(tierScores, bestTiers!); + if (compare < 0) + continue; + var parts = ToParts(placed, items, boxes, area); + if (best == null || compare > 0 || comparer.IsBetter(parts, best, area)) + { + best = parts; + bestTiers = tierScores; + } + } + + return best!; + } + + /// Positive when serves the most urgent tier better, negative when worse, 0 on a tie. + private static int CompareTiers((int Count, double Area)[] a, (int Count, double Area)[] b) + { + for (var i = 0; i < a.Length; i++) + { + if (a[i].Count != b[i].Count) + return a[i].Count > b[i].Count ? 1 : -1; + if (a[i].Area > b[i].Area + Tolerance.Epsilon) + return 1; + if (a[i].Area < b[i].Area - Tolerance.Epsilon) + return -1; + } + return 0; + } + + private static List ToParts( + List placed, IReadOnlyList items, IReadOnlyList boxes, Box area) + { + var parts = new List(placed.Count); + foreach (var p in placed) + { + var part = new Part(items[p.Type].Drawing); + if (p.Size == 1) + part.Rotate(Angle.HalfPI); + var bounds = p.Size == 1 ? part.Program.BoundingBox() : boxes[p.Type]; + part.Offset(new Vector(area.X + p.X, area.Y + p.Y) - bounds.Location); + parts.Add(part); + } + return parts; + } +} diff --git a/OpenNest.Engine/RectanglePacking/BinConverter.cs b/OpenNest.Engine/RectanglePacking/BinConverter.cs index 7fd82ca..7c67425 100644 --- a/OpenNest.Engine/RectanglePacking/BinConverter.cs +++ b/OpenNest.Engine/RectanglePacking/BinConverter.cs @@ -31,25 +31,6 @@ namespace OpenNest.Engine.RectanglePacking }; } - public static List ToItems(List items, double partSpacing, double plateArea) - { - var binItems = new List(); - - for (var i = 0; i < items.Count; i++) - { - var item = items[i]; - var binItem = ToItem(item, partSpacing, i); - - var maxQty = (int)System.Math.Floor(plateArea / binItem.Area()); - var qty = item.Quantity < maxQty ? item.Quantity : maxQty; - - for (var j = 0; j < qty; j++) - binItems.Add(binItem.Clone() as Item); - } - - return binItems; - } - public static List ToParts(Bin bin, List items) { var parts = new List(); diff --git a/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs b/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs deleted file mode 100644 index ef4c6d8..0000000 --- a/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine.RectanglePacking -{ - internal class PackBottomLeft : PackEngine - { - public PackBottomLeft(Bin bin) - : base(bin) { } - - public override void Pack(List items) - { - var byArea = items - .Select(i => i.Clone() as Item) - .OrderByDescending(i => i.Area()) - .ToList(); - var byLength = items - .Select(i => i.Clone() as Item) - .OrderByDescending(i => System.Math.Max(i.Width, i.Length)) - .ToList(); - - var resultA = PackWithOrder(byArea); - var resultB = PackWithOrder(byLength); - - var winner = PickWinner(resultA, resultB); - - Bin.Items.AddRange(winner); - } - - private List PackWithOrder(List items) - { - var points = new List { Bin.Location }; - var placed = new List(); - var skip = new List(); - - for (var i = 0; i < items.Count; i++) - { - var item = items[i]; - - if (skip.Contains(item.Id)) - continue; - - var pt = FindPointVertical(item, points, placed); - - // If it doesn't fit, try rotated. - if (pt == null) - { - item.Rotate(); - pt = FindPointVertical(item, points, placed); - } - - if (pt == null) - { - if (item.IsRotated) - item.Rotate(); - skip.Add(item.Id); - continue; - } - - item.Location = pt.Value; - - points.Remove(pt.Value); - points.Add(new Vector(item.Left, item.Top)); - points.Add(new Vector(item.Right, item.Bottom)); - - placed.Add(item); - } - - return placed; - } - - private static List PickWinner(List a, List b) - { - if (a.Count != b.Count) - return a.Count > b.Count ? a : b; - - if (a.Count == 0) - return a; - - var areaA = a.GetBoundingBox().Area(); - var areaB = b.GetBoundingBox().Area(); - - return areaB < areaA ? b : a; - } - - private Vector? FindPointVertical(Item item, List points, List placed) - { - var pt = new Vector(double.MaxValue, double.MaxValue); - - for (var i = 0; i < points.Count; i++) - { - var point = points[i]; - - item.Location = point; - - if (!IsValid(item, placed)) - continue; - - if (point.X < pt.X) - pt = point; - else if (point.X.IsEqualTo(pt.X) && point.Y < pt.Y) - pt = point; - } - - if (pt.X != double.MaxValue && pt.Y != double.MaxValue) - return pt; - - return null; - } - - private bool IsValid(Item item, List placed) - { - if (!Bin.Contains(item)) - return false; - - foreach (var it in placed) - { - if (item.Intersects(it)) - return false; - } - - return true; - } - } -} diff --git a/OpenNest.Engine/RectanglePacking/PackEngine.cs b/OpenNest.Engine/RectanglePacking/PackEngine.cs deleted file mode 100644 index 6fea89f..0000000 --- a/OpenNest.Engine/RectanglePacking/PackEngine.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace OpenNest.Engine.RectanglePacking -{ - internal abstract class PackEngine - { - public PackEngine(Bin bin) - { - Bin = bin; - } - - public Bin Bin { get; set; } - - public abstract void Pack(List items); - } -}