perf(engine): speed up round cutouts and re-plan Circles' last sheets

- The cutout router uses the circle packer alone for a round hole whose
  inserts are all round. Lattice and NFP never placed more there, and when
  demand exceeded what fit they spent 20+ minutes on one job.
- The cutout pre-pass skips a hole whose area is smaller than an insert's
  outline, so bolt holes no longer run the searches.
- Circles re-packs its last sheet, and its last two sheets together, onto
  each single stock and keeps the result when the whole job gets cheaper.

On 14 real round-part jobs costs are unchanged and pipeline time drops from
262 s to 144 s for Circles and 355 s to 245 s for Default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 14:54:01 -04:00
1 parent 0e4fd5f90f
commit f01b2cec67
4 files changed
+69 -23

No files matched your search

@@ -53,6 +53,9 @@ internal sealed class CutoutPipelinePrepass
var requirements = original.Parts.ToDictionary(p => p.Id);
var round = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
&& Item.Create(g).FillRatio >= CutoutCirclePacker.MinFillRatio);
// An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches.
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
// Smallest first: a frame can only hold smaller parts, so its candidate inner bundles
@@ -86,9 +89,10 @@ internal sealed class CutoutPipelinePrepass
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
{
token.ThrowIfCancellationRequested();
var holeArea = System.Math.Abs(profile.Cutouts[hole].Area());
var eligible = offers
.Where(p => p.Id != frame.Id && available[p.Id] > 0
&& p.Priority == frame.Priority
&& p.Priority == frame.Priority && outline[p.Id] <= holeArea
&& (!bundles.TryGetValue(p.Id, out var b)
|| (b.FrameId != frame.Id && b.Levels < MaxLevels)))
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
@@ -99,7 +103,7 @@ internal sealed class CutoutPipelinePrepass
? Array.Empty<NestJobPart>()
: eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray();
if (flat.Length == 0 && nested.Length == 0)
break;
continue;
foreach (var pose in Propose(frame, hole, flat, nested))
{
token.ThrowIfCancellationRequested();
@@ -133,6 +137,7 @@ internal sealed class CutoutPipelinePrepass
offers.Add(proxy);
requirements.Add(proxyId, proxy);
geometry.Add(proxyId, profile);
outline.Add(proxyId, outline[frame.Id]);
round.Add(proxyId, round[frame.Id]);
remaining.Add(proxyId, 1);
}
+7 -11
View File
@@ -9,8 +9,8 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. A round hole whose inserts are all round also gets a
/// circle-in-circle proposal for the whole list, kept only when it places more copies.
/// the large-insert path tries NFP first. A round hole whose inserts are all round gets only the
/// circle-in-circle proposal for the whole list.
/// All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
@@ -50,13 +50,11 @@ internal static class CutoutRouter
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
nameof(inserts));
// Nothing can place more than all demand, so a complete circle packing skips the
// lattice and NFP searches, which are slow on many round inserts.
var circles = CutoutCirclePacker.Applies(hole, inserts)
? CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, token)
: null;
if (circles != null && circles.Count == inserts.Sum(p => (long)System.Math.Max(0, p.Quantity)))
return Circles(circles);
// A round hole holding only round inserts is a circle packing problem: the lattice and
// NFP searches never placed more there, and they are slow on round inserts, most of all
// when demand exceeds what fits (every surplus copy is searched for and not found).
if (CutoutCirclePacker.Applies(hole, inserts))
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, token));
var accepted = new List<NestJobPlacement>();
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
@@ -138,8 +136,6 @@ internal static class CutoutRouter
}
}
token.ThrowIfCancellationRequested();
if (circles != null && circles.Count > accepted.Count)
return Circles(circles);
foreach (var (pose, lattice) in observed)
observeAccepted?.Invoke(pose, lattice);
return accepted.ToArray();
@@ -23,7 +23,9 @@ namespace OpenNest.Engine.NestingEngines.Circles;
/// and left-bottom (columns), plain and seeded with a hexagonal lattice of the type with the most
/// remaining area, and bottom-left over a square lattice of it; the candidate with the lowest
/// estimated whole-job cost wins. The whole job runs once with that free choice and once
/// preferring each offered stock, and the best result by <see cref="CandidateScore"/> is kept.
/// preferring each offered stock, and the best result by <see cref="CandidateScore"/> is kept;
/// its last sheet, and its last two sheets together, are then re-packed onto each single stock
/// and replaced when that lowers the whole job's cost.
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
/// </summary>
public sealed class CirclesNestingEngine : INestingEngine
@@ -71,19 +73,60 @@ public sealed class CirclesNestingEngine : INestingEngine
var forward = progress == null ? null : new CandidateProgress(progress);
CandidateScore? best = null;
var runs = new NestPlateStock?[] { null }.Concat(job.Plates.Count > 1 ? job.Plates : Array.Empty<NestPlateStock>());
var order = 0;
var run = 0;
foreach (var preferred in runs)
{
token.ThrowIfCancellationRequested();
var result = SheetByStock.Run(job, demand, remaining.ToArray(), Pack, forward, token,
preferred: preferred);
var scored = CandidateScore.Of(job, result, order++);
var scored = CandidateScore.Of(job, result, run++);
if (best == null || scored.IsBetterThan(best))
best = scored;
}
CandidateProgress.ReportCommits(best!.Result, progress);
best = ReplanTail(best!);
CandidateProgress.ReportCommits(best.Result, progress);
return best.Result;
// The last sheet is packed from whatever demand is left, often on a larger sheet than
// it needs: re-pack the last sheet, then the last two together, onto each single stock.
CandidateScore ReplanTail(CandidateScore chosen)
{
var index = types.ToDictionary(t => t.Id, t => t.Index, StringComparer.Ordinal);
for (var tail = 1; tail <= System.Math.Min(2, chosen.Result.Plates.Count); tail++)
{
var sheets = chosen.Result.Plates;
var keep = sheets.Take(sheets.Count - tail).ToList();
var replaced = sheets.Skip(keep.Count).ToList();
var left = new int[types.Count];
foreach (var pose in replaced.SelectMany(p => p.Placements))
left[index[pose.PartId]]++;
var count = left.Sum();
var replacedCost = replaced.Sum(p => NestJobCost.NetSheetCost(job, p));
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
if (stock.Quantity is int available && keep.Count(p => p.StockId == stock.Id) >= available)
continue;
foreach (var layout in Pack(stock, left))
{
// Only a complete, cheaper tail can lower the whole job's cost.
if (layout.Poses.Count < count || layout.Envelope is not { } envelope
|| NestJobCost.NetSheetCost(job.Options, stock, envelope) >= replacedCost)
continue;
var builder = new NestJobResultBuilder(job);
foreach (var sheet in keep)
builder.AddSheet(job.Plates.Single(s => s.Id == sheet.StockId),
sheet.Placements.Select(p => (p.PartId, p.X, p.Y, p.Rotation)));
builder.AddSheet(stock, layout.Poses);
var scored = CandidateScore.Of(job, builder.Build(chosen.Result.StopReason), run++);
if (scored.IsBetterThan(chosen))
chosen = scored;
}
}
}
return chosen;
}
IEnumerable<SheetLayout> Pack(NestPlateStock stock, IReadOnlyList<int> left)
{
if (!types.Any(t => left[t.Index] > 0 && DiscPacker.Fits(stock, t)))
+8 -6
View File
@@ -13,7 +13,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; ties keep the earlier engine |
| 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 |
| 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 |
| 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 |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
@@ -209,7 +209,9 @@ ones. Sheets and stock are chosen by the loop Rectangles uses (`NestingEngines/S
salvage-credited look-ahead cost, priority first, and repeated completed layouts. That per-sheet
estimate can take a large sheet first and still need a second, so Circles runs the whole job once
with a free stock choice and once preferring each offered stock, and keeps the best result as
Default ranks candidates (valid, fewest unplaced, lowest cost).
Default ranks candidates (valid, fewest unplaced, lowest cost). The last sheet, and the last two
sheets together, are then re-packed onto each single stock and replaced when that lowers the
whole job's cost, so a small remainder does not finish on a large sheet.
## Filling cutouts
@@ -248,14 +250,14 @@ list as discs: largest first, at centers tangent to two of the hole wall and the
or at the hole's center. It tries a center-out pass, so a single insert sits concentric, and a
wall-first pass, which reaches the optimal counts for up to nine equal discs. Each gap is the
spacing plus twice `NestTolerances.ValidationOutline`, and every pose passes `NestLayoutCheck`.
If it places all demand, the router skips the lattice and NFP searches, which are slow on many
round inserts. Otherwise it keeps whichever proposal places more copies, and the existing
proposals win ties.
Its result is the router's proposal for that hole: the lattice and NFP searches never placed
more there and were slow on round inserts, most of all when demand exceeds what fits.
`CutoutPipelinePrepass` builds quantity-one proxy instances for eligible
closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry
reduction, shifts insert rotation policies into the local frame, and reserves certified router
poses by original requirement ID. It can fill multiple holes and repeated/different frame
requirements; inserts with a different priority stay independent. Frames are bundled smallest
requirements; inserts with a different priority stay independent, and a hole smaller in area than
an insert's outline (a bolt hole) is not searched for it. Frames are bundled smallest
first, so a larger frame's round cutout can take, through `CutoutCirclePacker` only, a round part
with its own cutouts (a washer with an empty bore) or an earlier bundle as one insert: a flange
holds a washer that holds a smaller washer that holds a slug. Chains stop at four parts deep