feat(engine): group like parts in Irregular's grouped variants

After its six variants and tail re-plan, Irregular decodes the job again
with each type of three or more copies packed as one group, in a searched
order, holding back up to four copies per group that would start a ragged
row and filling them into the gaps afterward. When everything fits, the
best candidates are re-packed in a strip ending just short of the front.

The pass runs after the ungrouped search, which is unchanged, and replaces
its result only by beating it. The help tutorial's parts now end at 34.9"
instead of 38.1" on 60 x 120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-02 18:41:33 -05:00
1 parent a2966b9f86
commit 91089820ee
7 files changed
+431 -17

No files matched your search

@@ -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<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[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 }));
}
}
@@ -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<long>();
using var cancel = new CancellationTokenSource();
var engine = new IrregularNestingEngine
{
GroupLikeParts = false,
// The normal search packs exactly normalSheets sheets; later ones are extended.
TailReplanning = budget =>
{
@@ -64,6 +64,7 @@ internal sealed class FrontierPacker
private readonly PackAxis axis;
private readonly double beta;
private readonly IReadOnlyList<double>? typeWeights;
private readonly IReadOnlyList<int>? groupRanks;
/// <summary>
/// The placement rule's value of a candidate; lower wins. Gap fill prefers bigger parts
@@ -82,6 +83,10 @@ internal sealed class FrontierPacker
/// <param name="boundary">Outline to fill instead of the stock's work area; it must be
/// fitted to <paramref name="nfps"/>.</param>
/// <param name="groupRanks">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.</param>
/// <param name="reach">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.</param>
public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
@@ -93,7 +98,9 @@ internal sealed class FrontierPacker
BlockCatalog? blocks = null,
ClampObstacles? clamps = null,
PackBoundary? boundary = null,
IReadOnlyList<double>? typeWeights = null
IReadOnlyList<double>? typeWeights = null,
IReadOnlyList<int>? 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));
/// <param name="seated">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.</param>
@@ -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<IReadOnlyList<Placed>> 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;
@@ -0,0 +1,193 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Fills one sheet with like parts grouped: each type with three or more copies packs as one
/// group, the groups in turn (<see cref="FrontierPacker"/> 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 <see cref="WorkCounter"/>; the search starts no fill once
/// the budget is spent and keeps the best sheet so far.
/// </summary>
internal sealed class GroupedFill(
IReadOnlyList<PartType> types,
Func<IReadOnlyList<int>?, double?, FrontierPacker> packer,
PackAxis axis,
bool shrink,
Comparison<SheetFill> compare,
WorkCounter counter,
long budget)
{
/// <summary>Fewest copies of a type that pack as a group.</summary>
internal const int MinGroup = 3;
/// <summary>Group counts up to this are searched in every order; more use ranked orders.</summary>
private const int MaxPermuted = 4;
/// <summary>Best candidates carried into hold-back search and shorter strips.</summary>
private const int Refined = 3;
/// <summary>Most copies held back from one group.</summary>
private const int MaxHeldBack = 4;
/// <summary>Most shorter strips tried once everything fits the sheet.</summary>
private const int MaxShrinks = 4;
/// <summary>How much shorter than the best front each shorter strip ends.</summary>
private const double Shrink = 0.25;
/// <summary>Whether grouping can change anything: two or more types of which one groups.</summary>
public static bool Applies(IReadOnlyList<PartType> types, IReadOnlyList<int> 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<int> 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);
/// <summary>Packs each (order, held) in turn, best first; ties keep the earlier one. At least
/// one is packed whatever the budget.</summary>
private List<Candidate> Evaluate(IEnumerable<(int[] Order, int[] Held)> seeds, IReadOnlyList<int> remaining,
double? reach, CancellationToken token)
{
var results = new List<Candidate>();
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);
}
/// <summary>Holds back copies of one type at a time, keeping each count that improves the sheet.</summary>
private List<Candidate> Refine(IEnumerable<Candidate> starts, IReadOnlyList<int> remaining, double? reach,
CancellationToken token)
{
var results = new List<Candidate>();
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<Candidate> Sort(List<Candidate> candidates) =>
candidates.Select((c, i) => (c, i)).OrderBy(x => x.c.Fill, Comparer<SheetFill>.Create(compare))
.ThenBy(x => x.i).Select(x => x.c).ToList();
/// <summary>The groups packed in <paramref name="order"/>, less the held-back copies, then
/// everything else filling around them.</summary>
private SheetFill Run(int[] order, int[] held, IReadOnlyList<int> 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,
};
}
/// <summary>
/// Every order of up to <see cref="MaxPermuted"/> groups; otherwise the groups ranked by total
/// area, part area and part size, largest first and last.
/// </summary>
private IEnumerable<int[]> Orders(int[] grouped, IReadOnlyList<int> remaining)
{
if (grouped.Length <= MaxPermuted)
return Permutations(grouped);
var size = new Func<int, double>(t => types[t].Orientations.Max(o => System.Math.Max(o.Width, o.Height)));
var keys = new Func<int, double>[] { t => types[t].Area * remaining[t], t => types[t].Area, size };
var orders = new List<int[]>();
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<int[]> 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();
}
}
}
@@ -42,6 +42,20 @@ public sealed class IrregularNestingEngine : INestingEngine
(PackAxis.Y, 1.5),
};
/// <summary>
/// Front directions of the grouped variants, tried after <see cref="Variants"/>: along the
/// length of the stock, and across it too when some stock is taller than it is long.
/// </summary>
internal static IEnumerable<PackAxis> GroupedAxes(NestJob job)
{
yield return PackAxis.X;
if (job.Plates.Any(s => s.WorkArea.Width > s.WorkArea.Length))
yield return PackAxis.Y;
}
/// <summary>Whether the grouped variants run; off only in tests of the ungrouped search.</summary>
internal bool GroupLikeParts { get; init; } = true;
/// <summary>
/// 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
}
/// <summary>
/// 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.
/// </summary>
private void SearchUntilCancelled(
@@ -142,15 +176,20 @@ public sealed class IrregularNestingEngine : INestingEngine
Solver solver,
int[] demand,
Plan best,
bool groups,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
{
// Grouped decodes take their turn among the variants when the job groups.
var grouped = groups ? GroupedAxes(job).ToArray() : Array.Empty<PackAxis>();
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<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
/// <summary>Work at which the current grouped plan stops starting new fills.</summary>
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<double>? weights = null)
/// <summary>
/// One sheet against another for the same demand: more part area placed first, then lower net
/// cost, then the larger compactness offcut; negative when <paramref name="a"/> is better.
/// </summary>
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<double>? weights = null, bool grouped = false)
{
groupedBudget = Work.Value + workBudget;
var run = Decode(demand, axis, beta, new Dictionary<string, int>(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<string, int> usedBefore,
int? sheetCap,
NestPlateStock? first,
IReadOnlyList<double>? weights = null
IReadOnlyList<double>? 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<int>? 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)));
@@ -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;
/// <summary>
/// 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".
/// </summary>
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}");
}
}
+29 -2
View File
@@ -14,7 +14,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, 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