diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularGroupedTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularGroupedTests.cs new file mode 100644 index 00000000..e9fb24c4 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularGroupedTests.cs @@ -0,0 +1,77 @@ +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Geometry; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class IrregularGroupedTests +{ + private static readonly IReadOnlyDictionary> NoPairs = + new Dictionary>(); + + [Fact] + public void GroupRanksPlaceEachTypeAsOneGroupInRankOrder() + { + var stock = Stock("sheet", 30, 60, spacing: 0.25); + var job = Job(new[] { Part("box", Rectangle(4, 4), 8), Part("tri", Triangle(5, 5), 8) }, new[] { stock }); + var types = PartCatalog.Build(job); + + var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock, PackAxis.X, 1, new WorkCounter(), + groupRanks: new[] { 1, 0 }).Fill(new[] { 8, 8 }, CancellationToken.None); + + var order = fill.Parts.Select(p => p.Orientation.TypeIndex).ToArray(); + Assert.Equal(Enumerable.Repeat(1, 8).Concat(Enumerable.Repeat(0, 8)), order); + } + + [Fact] + public void ReachEndsTheWorkAreaAlongTheFront() + { + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] { Part("box", Rectangle(4, 4), 40) }, new[] { stock }); + var types = PartCatalog.Build(job); + + var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock, PackAxis.X, 1, new WorkCounter(), + reach: 13).Fill(new[] { 40 }, CancellationToken.None); + + Assert.Equal(12, fill.Parts.Count); // Three columns of four. + Assert.All(fill.Parts, p => Assert.True(p.Right <= 13 + 1e-9, $"Right {p.Right}")); + } + + [Fact] + public void GroupedLayoutOfMixedRepeatedPartsIsValidAndComplete() + { + // The help sample's four parts: gussets, ells, plates and flanges in quantity. + var job = Job( + new[] + { + Part("gusset", Polyline((0, 0), (5, 0), (0, 5)), 30, RotationPolicy.Automatic), + Part("ell", LShape(6, 6, 2), 20, RotationPolicy.Automatic), + Part("plate", Rectangle(5, 8), 10, RotationPolicy.Automatic), + Part("flange", Disc(3), 12, RotationPolicy.Automatic), + }, + new[] { Stock("sheet", 60, 120, spacing: 0.3, edge: new Spacing(0.3, 0.75, 0.3, 0.25)) }); + + var engine = new IrregularNestingEngine(); + var result = engine.Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(result.Plates.Single().Placements, engine.Solve(job).Plates.Single().Placements); + } + + [Fact] + public void GroupingNeedsTwoTypesAndOneOfThreeCopies() + { + var job = Job(new[] { Part("a", Rectangle(2, 2), 3), Part("b", Rectangle(3, 2), 2) }, + new[] { Stock("sheet", 20, 20) }); + var types = PartCatalog.Build(job); + + Assert.True(GroupedFill.Applies(types, new[] { 3, 2 })); + Assert.False(GroupedFill.Applies(types, new[] { 2, 2 })); + Assert.False(GroupedFill.Applies(types, new[] { 3, 0 })); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularSearchUntilCancelledTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularSearchUntilCancelledTests.cs index 35f80267..3fa5e4ce 100644 --- a/OpenNest.Engine.Tests/NestingEngines/IrregularSearchUntilCancelledTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularSearchUntilCancelledTests.cs @@ -50,15 +50,18 @@ public class IrregularSearchUntilCancelledTests public void KeepsSearchingAfterTheNormalSearchUntilCancelled() { var plain = new Recorder(); - var plainResult = new IrregularNestingEngine().Solve(MixedJob(false), plain); + // Grouped, this job's normal search already finds a plan the seeds do not beat within the + // sheets allowed here; this test is of the seeded search, so it runs ungrouped. + var plainResult = new IrregularNestingEngine { GroupLikeParts = false }.Solve(MixedJob(false), plain); var normalSheets = 0; - new IrregularNestingEngine { SheetPacked = () => normalSheets++ }.Solve(MixedJob(false)); + new IrregularNestingEngine { GroupLikeParts = false, SheetPacked = () => normalSheets++ }.Solve(MixedJob(false)); var extendedSheets = 0; var extendedTailBudgets = new List(); using var cancel = new CancellationTokenSource(); var engine = new IrregularNestingEngine { + GroupLikeParts = false, // The normal search packs exactly normalSheets sheets; later ones are extended. TailReplanning = budget => { diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index 04ff5c39..aecabab2 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -64,6 +64,7 @@ internal sealed class FrontierPacker private readonly PackAxis axis; private readonly double beta; private readonly IReadOnlyList? typeWeights; + private readonly IReadOnlyList? groupRanks; /// /// The placement rule's value of a candidate; lower wins. Gap fill prefers bigger parts @@ -82,6 +83,10 @@ internal sealed class FrontierPacker /// Outline to fill instead of the stock's work area; it must be /// fitted to . + /// Per type, a rank served after priority: a type is placed only when + /// no type of the same priority and lower rank can be, so each type packs as one group. + /// Farthest the packing front may go: the work area ends there along the + /// front axis, so the fill must fit a shorter strip of the sheet. public FrontierPacker( IReadOnlyList types, NoFitCache nfps, @@ -93,7 +98,9 @@ internal sealed class FrontierPacker BlockCatalog? blocks = null, ClampObstacles? clamps = null, PackBoundary? boundary = null, - IReadOnlyList? typeWeights = null + IReadOnlyList? typeWeights = null, + IReadOnlyList? groupRanks = null, + double? reach = null ) { if (boundary != null && !ReferenceEquals(boundary.Nfps, nfps)) @@ -108,12 +115,21 @@ internal sealed class FrontierPacker this.axis = axis; this.beta = beta; this.typeWeights = typeWeights; + this.groupRanks = groupRanks; this.boundary = boundary; work = boundary?.Bounds ?? stock.WorkArea; + if (reach is double end) + work = axis == PackAxis.X + ? new Box(work.Left, work.Bottom, System.Math.Clamp(end - work.Left, 0, work.Length), work.Width) + : new Box(work.Left, work.Bottom, work.Length, System.Math.Clamp(end - work.Bottom, 0, work.Width)); + limited = reach != null; } + private readonly bool limited; + private bool Fits(double width, double height) => - boundary?.Fits(width, height) ?? stock.Fits(width, height); + (boundary?.Fits(width, height) ?? stock.Fits(width, height)) + && (!limited || (width <= work.Length + 1e-9 && height <= work.Width + 1e-9)); /// Parts already on the sheet: they block space and hold the packing /// front where they reached, so the fill starts as gap fill. They are not returned. @@ -279,17 +295,20 @@ internal sealed class FrontierPacker var bestSide = double.PositiveInfinity; var bestLead = double.PositiveInfinity; var bestPriority = int.MaxValue; + var bestRank = int.MaxValue; void Consider(Func> build, int typeIndex, double area, double advance, double side, double lead) { var priority = types[typeIndex].Part.Priority; - if (priority > bestPriority) + var rank = groupRanks?[typeIndex] ?? 0; + if (priority > bestPriority || (priority == bestPriority && rank > bestRank)) return; var fills = advance <= Tie; var value = PlacementValue(fills, advance, area, beta, typeWeights?[typeIndex] ?? 1.0); var better = bestParts == null || priority < bestPriority + || rank < bestRank || (fills && !bestFills) || ( fills == bestFills @@ -305,6 +324,7 @@ internal sealed class FrontierPacker return; bestParts = build(); bestPriority = priority; + bestRank = rank; bestFills = fills; bestValue = value; bestSide = side; diff --git a/OpenNest.Engine/NestingEngines/Irregular/GroupedFill.cs b/OpenNest.Engine/NestingEngines/Irregular/GroupedFill.cs new file mode 100644 index 00000000..33044935 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/GroupedFill.cs @@ -0,0 +1,193 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Fills one sheet with like parts grouped: each type with three or more copies packs as one +/// group, the groups in turn ( group ranks), and then the copies held +/// back from the groups and the types too few to group fill the gaps the groups left. +/// +/// Holding copies back is what keeps the groups dense: the last copies of a group often start a +/// new row or column on their own, where they block room a later group could use whole; placed +/// last, they drop into whatever gaps remain. The search tries group orders with nothing held +/// back, then, from the best orders, holds back copies type by type while that improves the sheet. +/// When the sheet then holds everything, the best candidates and every order are packed again +/// inside a strip ending just short of the front, while that still holds everything and improves the sheet: +/// Fill blocks are sized to the free room, so a shorter strip yields denser groups. +/// +/// Every fill is metered by the shared ; the search starts no fill once +/// the budget is spent and keeps the best sheet so far. +/// +internal sealed class GroupedFill( + IReadOnlyList types, + Func?, double?, FrontierPacker> packer, + PackAxis axis, + bool shrink, + Comparison compare, + WorkCounter counter, + long budget) +{ + /// Fewest copies of a type that pack as a group. + internal const int MinGroup = 3; + + /// Group counts up to this are searched in every order; more use ranked orders. + private const int MaxPermuted = 4; + + /// Best candidates carried into hold-back search and shorter strips. + private const int Refined = 3; + + /// Most copies held back from one group. + private const int MaxHeldBack = 4; + + /// Most shorter strips tried once everything fits the sheet. + private const int MaxShrinks = 4; + + /// How much shorter than the best front each shorter strip ends. + private const double Shrink = 0.25; + + /// Whether grouping can change anything: two or more types of which one groups. + public static bool Applies(IReadOnlyList types, IReadOnlyList remaining) => + types.Count(t => remaining[t.Index] > 0) > 1 && types.Any(t => remaining[t.Index] >= MinGroup); + + private sealed record Candidate(int[] Order, int[] Held, SheetFill Fill); + + public SheetFill Fill(IReadOnlyList remaining, CancellationToken token) + { + var grouped = types.Where(t => remaining[t.Index] >= MinGroup && t.Orientations.Count > 0) + .Select(t => t.Index).ToArray(); + var none = new int[types.Count]; + var orders = Orders(grouped, remaining).ToList(); + var ranked = Evaluate(orders.Select(o => (o, none)), remaining, null, token); + ranked = Refine(ranked.Take(Refined), remaining, null, token); + var best = ranked[0].Fill; + + var total = remaining.Sum(); + for (var i = 0; shrink && i < MaxShrinks && best.Parts.Count == total && counter.Value < budget; i++) + { + var reach = Front(best) - Shrink; + var seeds = ranked.Take(Refined).Select(c => (c.Order, c.Held)).Concat(orders.Select(o => (o, none))); + var tried = Evaluate(seeds, remaining, reach, token); + tried = Refine(tried.Take(1), remaining, reach, token).Concat(tried.Skip(1)).ToList(); + if (tried[0].Fill.Parts.Count < total || compare(tried[0].Fill, best) >= 0) + break; + best = tried[0].Fill; + ranked = tried; + } + return best; + } + + private double Front(SheetFill fill) => + axis == PackAxis.X ? fill.Parts.Max(p => p.Right) : fill.Parts.Max(p => p.Top); + + /// Packs each (order, held) in turn, best first; ties keep the earlier one. At least + /// one is packed whatever the budget. + private List Evaluate(IEnumerable<(int[] Order, int[] Held)> seeds, IReadOnlyList remaining, + double? reach, CancellationToken token) + { + var results = new List(); + foreach (var (order, held) in seeds) + { + if (results.Count > 0 && counter.Value >= budget) + break; + results.Add(new Candidate(order, held, Run(order, held, remaining, reach, token))); + } + return Sort(results); + } + + /// Holds back copies of one type at a time, keeping each count that improves the sheet. + private List Refine(IEnumerable starts, IReadOnlyList remaining, double? reach, + CancellationToken token) + { + var results = new List(); + foreach (var start in starts) + { + var incumbent = start; + foreach (var type in start.Order) + { + var limit = System.Math.Min(MaxHeldBack, remaining[type] - MinGroup); + for (var count = 0; count <= limit && counter.Value < budget; count++) + { + if (count == incumbent.Held[type]) + continue; + var held = incumbent.Held.ToArray(); + held[type] = count; + var fill = Run(start.Order, held, remaining, reach, token); + if (compare(fill, incumbent.Fill) < 0) + incumbent = new Candidate(start.Order, held, fill); + } + } + results.Add(incumbent); + } + return Sort(results); + } + + private List Sort(List candidates) => + candidates.Select((c, i) => (c, i)).OrderBy(x => x.c.Fill, Comparer.Create(compare)) + .ThenBy(x => x.i).Select(x => x.c).ToList(); + + /// The groups packed in , less the held-back copies, then + /// everything else filling around them. + private SheetFill Run(int[] order, int[] held, IReadOnlyList remaining, double? reach, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var ranks = new int[types.Count]; + var groups = new int[types.Count]; + for (var i = 0; i < order.Length; i++) + { + ranks[order[i]] = i; + groups[order[i]] = remaining[order[i]] - held[order[i]]; + } + var first = packer(ranks, reach).Fill(groups, token); + var rest = remaining.ToArray(); + foreach (var part in first.Parts) + rest[part.Orientation.TypeIndex]--; + if (rest.All(r => r == 0)) + return first; + var second = packer(null, reach).Fill(rest, token, first.Parts); + return first with + { + Parts = first.Parts.Concat(second.Parts).ToList(), + PartArea = first.PartArea + second.PartArea, + }; + } + + /// + /// Every order of up to groups; otherwise the groups ranked by total + /// area, part area and part size, largest first and last. + /// + private IEnumerable Orders(int[] grouped, IReadOnlyList remaining) + { + if (grouped.Length <= MaxPermuted) + return Permutations(grouped); + var size = new Func(t => types[t].Orientations.Max(o => System.Math.Max(o.Width, o.Height))); + var keys = new Func[] { t => types[t].Area * remaining[t], t => types[t].Area, size }; + var orders = new List(); + foreach (var key in keys) + { + var descending = grouped.OrderByDescending(key).ThenBy(t => t).ToArray(); + foreach (var order in new[] { descending, descending.Reverse().ToArray() }) + if (!orders.Any(o => o.SequenceEqual(order))) + orders.Add(order); + } + return orders; + } + + private static IEnumerable Permutations(int[] items) + { + if (items.Length <= 1) + { + yield return items.ToArray(); + yield break; + } + for (var i = 0; i < items.Length; i++) + { + var rest = items.Where((_, j) => j != i).ToArray(); + foreach (var tail in Permutations(rest)) + yield return new[] { items[i] }.Concat(tail).ToArray(); + } + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index d26038e9..80a7b815 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -42,6 +42,20 @@ public sealed class IrregularNestingEngine : INestingEngine (PackAxis.Y, 1.5), }; + /// + /// Front directions of the grouped variants, tried after : along the + /// length of the stock, and across it too when some stock is taller than it is long. + /// + internal static IEnumerable GroupedAxes(NestJob job) + { + yield return PackAxis.X; + if (job.Plates.Any(s => s.WorkArea.Width > s.WorkArea.Length)) + yield return PackAxis.Y; + } + + /// Whether the grouped variants run; off only in tests of the ungrouped search. + internal bool GroupLikeParts { get; init; } = true; + /// /// Deterministic work budget, in free-region subtractions, after which no further variant /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. @@ -111,11 +125,31 @@ public sealed class IrregularNestingEngine : INestingEngine var improved = solver.ImproveTail(best!, WorkBudget * 2); if (!ReferenceEquals(improved, best)) ReportPlan(job, types, improved, progress, token); + + // Like parts grouped, the groups' stragglers filling the gaps after them. These run last, on + // their own budget, so the search above is the same with or without them; a grouped plan + // replaces its result only by beating it. Like the variants, none starts once the work + // budget is spent. + var groups = GroupLikeParts && GroupedFill.Applies(types, demand); + if (groups) + foreach (var axis in GroupedAxes(job)) + { + token.ThrowIfCancellationRequested(); + if (solver.Work.Value >= WorkBudget) + break; + var plan = solver.Plan(demand, axis, 1.0, grouped: true); + if (!plan.IsBetterThan(improved)) + continue; + ReportPlan(job, types, plan, progress, token); + improved = solver.ImproveTail(plan, solver.Work.Value + WorkBudget * 2); + if (!ReferenceEquals(improved, plan)) + ReportPlan(job, types, improved, progress, token); + } // More searching can only end in cancellation, so it needs a listener for its plans, and // it needs demand and two or more types, or every weighted decode repeats a variant. if (job.Options.SearchUntilCancelled && token.CanBeCanceled && progress != null && types.Count > 1 && demand.Any(d => d > 0)) - SearchUntilCancelled(job, types, solver, demand, improved, progress, token); + SearchUntilCancelled(job, types, solver, demand, improved, groups, progress, token); return BuildResult(job, types, improved, progress, token); } @@ -133,7 +167,7 @@ public sealed class IrregularNestingEngine : INestingEngine } /// - /// Decodes seed after seed, cycling the variants, and re-plans the tail of each plan that + /// Decodes seed after seed, cycling the variants and grouped variants, and re-plans the tail of each plan that /// beats the best so far; ends only when the token is cancelled. /// private void SearchUntilCancelled( @@ -142,15 +176,20 @@ public sealed class IrregularNestingEngine : INestingEngine Solver solver, int[] demand, Plan best, + bool groups, IProgress? progress, CancellationToken token ) { + // Grouped decodes take their turn among the variants when the job groups. + var grouped = groups ? GroupedAxes(job).ToArray() : Array.Empty(); for (var seed = 1; ; seed = seed == int.MaxValue ? 1 : seed + 1) { token.ThrowIfCancellationRequested(); - var (axis, beta) = Variants[seed % Variants.Length]; - var plan = solver.Plan(demand, axis, beta, SeedWeights(seed, types.Count)); + var pick = seed % (Variants.Length + grouped.Length); + var plan = pick < Variants.Length + ? solver.Plan(demand, Variants[pick].Axis, Variants[pick].Beta, SeedWeights(seed, types.Count)) + : solver.Plan(demand, grouped[pick - Variants.Length], 1.0, SeedWeights(seed, types.Count), grouped: true); if (!plan.IsBetterThan(best)) continue; // Report before the tail re-plan, which the limit may cut short. @@ -222,6 +261,9 @@ public sealed class IrregularNestingEngine : INestingEngine private readonly Dictionary blocks = new(); private readonly IReadOnlyDictionary parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); + /// Work at which the current grouped plan stops starting new fills. + private long groupedBudget; + private BlockCatalog BlocksFor(NestPlateStock stock) { var spacing = System.Math.Max(0, stock.PartSpacing); @@ -234,10 +276,27 @@ public sealed class IrregularNestingEngine : INestingEngine private double Penalty => NestJobCost.UnplacedPartPenalty(job); - public Plan Plan(int[] demand, PackAxis axis, double beta, IReadOnlyList? weights = null) + /// + /// One sheet against another for the same demand: more part area placed first, then lower net + /// cost, then the larger compactness offcut; negative when is better. + /// + private int CompareSheets(SheetFill a, SheetFill b) { + static int Compare(double x, double y) => + System.Math.Abs(x - y) <= 1e-9 * System.Math.Max(1, System.Math.Max(System.Math.Abs(x), System.Math.Abs(y))) + ? 0 : x.CompareTo(y); + var area = Compare(b.PartArea, a.PartArea); + if (area != 0) + return area; + var cost = Compare(NetCost(job.Options, a), NetCost(job.Options, b)); + return cost != 0 ? cost : Compare(Offcut(job.Options, b), Offcut(job.Options, a)); + } + + public Plan Plan(int[] demand, PackAxis axis, double beta, IReadOnlyList? weights = null, bool grouped = false) + { + groupedBudget = Work.Value + workBudget; var run = Decode(demand, axis, beta, new Dictionary(StringComparer.Ordinal), job.Options.MaxPlates, null, - weights); + weights, grouped); var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count); var reason = run.Reason; if (unplaced > 0 && reason == NestJobStopReason.Completed) @@ -358,7 +417,8 @@ public sealed class IrregularNestingEngine : INestingEngine IReadOnlyDictionary usedBefore, int? sheetCap, NestPlateStock? first, - IReadOnlyList? weights = null + IReadOnlyList? weights = null, + bool grouped = false ) { var remaining = (int[])demand.Clone(); @@ -390,9 +450,15 @@ public sealed class IrregularNestingEngine : INestingEngine if (stock.Quantity is int available && used[stock.Id] >= available) continue; progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); - var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, - BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock), weights); - var fill = packer.Fill(remaining, token); + // Grouped fills pack groups from singles and pairs: Fill blocks cost far more per + // fill there and gave no denser groups. + FrontierPacker Packer(IReadOnlyList? ranks, double? reach) => new(types, CacheFor(stock), + PairsFor(stock), stock, axis, beta, Work, grouped ? null : BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock), + weights, ranks, reach); + var fill = grouped && GroupedFill.Applies(types, remaining) + ? new GroupedFill(types, Packer, axis, stock.Outline == null, CompareSheets, Work, groupedBudget) + .Fill(remaining, token) + : Packer(null, null).Fill(remaining, token); sheetPacked?.Invoke(); if (fill.Parts.Count > 0) trials.Add((fill, NetCost(job.Options, fill))); diff --git a/OpenNest.Tests/Engine/GroupedNestingTests.cs b/OpenNest.Tests/Engine/GroupedNestingTests.cs new file mode 100644 index 00000000..207f6731 --- /dev/null +++ b/OpenNest.Tests/Engine/GroupedNestingTests.cs @@ -0,0 +1,28 @@ +using OpenNest.Benchmark; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Tests.CuttingPlanning; + +namespace OpenNest.Tests.Engine; + +/// +/// The help tutorial's parts (30 gussets, 20 ells, 10 plates, 12 flanges on a 60 x 120 sheet) nest +/// with like parts grouped. Before grouping, Irregular ended the layout at 38.06"; a hand-grouped +/// layout of the same parts ends at 34.1". +/// +public class GroupedNestingTests +{ + [Fact] + public void TutorialPartsNestInAShorterStripWhenGrouped() + { + var path = Path.Combine(PlanningCharacterizationTests.RepositoryRoot(), "test-data", "help-tutorial", "first-nest.nest"); + var job = Assert.Single(JobLoader.Load(path)).BuildNestJob(1); + + var result = new IrregularNestingEngine().Solve(job); + + var plate = Assert.Single(NestResultMaterializer.Materialize(job, result).Nest.Plates); + Assert.Equal(72, plate.Parts.Count); + var right = plate.Parts.Max(p => p.BoundingBox.Right); + Assert.True(right < 35.5, $"Layout ends at {right:F3}"); + } +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 9aac0089..df1a213d 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -14,7 +14,7 @@ in `OpenNest.Engine/NestingEngines//`, its tests in `OpenNest.Engine.Tests | Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost, then the largest compactness offcut; remaining ties keep the engine listed first here | | Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost | | Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan | -| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan; under a time limit it keeps trying seeded variants until the limit | +| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together; under a time limit it keeps trying seeded variants until the limit | | FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost | | Lattice, Strip, Vertical Remnant, Horizontal Remnant | Many copies of a few parts | The fixed placement strategies behind interactive fill; Lattice is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder), and the remnant strategies run it ranked to keep the largest drop. In whole jobs, all four place a plate with any fixed or bounded part rotation by the rotation-aware ordered placer instead | @@ -65,7 +65,7 @@ proposals with no committed counts, all come before the plate commits, and the l returned layout. Reporting never changes the search: layouts are identical with or without a progress sink. -- Irregular reports when a strategy variant or the tail re-plan beats its best plan so far. +- Irregular reports when a strategy variant, a grouped variant or a tail re-plan beats its best plan so far. - Rectangles makes one sheet-by-sheet run, so it reports the finished job as its one plan. - Circles reports when a stock-preference run or a tail re-plan beats its best result so far. - Default scores each candidate plan as it ranks results (valid, fewest unplaced, lowest cost, @@ -199,6 +199,33 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock pending the hole-geometry integration; containment cutting order and shop-use safety acceptance remain separate sequencer/verification work. +### Grouped variants + +After its six variants and the tail re-plan, Irregular decodes the job again with like parts +grouped (`GroupedFill`), when at least two types have demand and one has three or more copies. Each +sheet packs every such type as one group, in turn: the packer serves a type only when no type of +the same priority and lower group rank can still be placed. The copies held back from the groups +and the types with one or two copies then fill around them under the usual rule. Holding copies +back keeps groups dense: a group's last few copies often start a row of their own and block room +the next group could use whole, while placed last they drop into whatever gaps remain. + +Per sheet, the search packs every order of up to four groups (more groups use the orders by total +area, part area and part size, each largest first and last), then holds back zero to four copies +of each group in turn, keeping each count that improves the sheet, for the three best orders. When +the sheet then holds everything, the best candidates and every order are packed again inside a +strip ending a quarter unit short of the best front (the packer's `reach`), at most four times, +while that still holds everything and improves the sheet. Sheets rank by part area placed, then +net cost, then compactness offcut. Grouped fills use singles and pairs only: Fill blocks made each +fill several times slower for no denser groups. + +The grouped variants front along the stock's length, and also across it when some stock is taller +than it is long. Like the other variants, none starts once the work budget is spent; each then has +a budget of its own. They run after the ungrouped search, so that search and its result are +unchanged; a grouped plan replaces the result only by beating it, and then has its own tail +re-plan. The search until cancelled takes grouped decodes in turn with the variants, with the +same seeded weights. On the help tutorial's parts (30 gussets, 20 ells, 10 plates, 12 flanges on +60 x 120 at 0.3 spacing) the layout ends at 34.9 rather than 38.1. + ## Flat bar FlatBar treats each stock's long work-area axis as the bar's length and lays parts in a single