From f01b2cec67abe17f8135c320d0d52a6b6ee6a81e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 14:54:01 -0400 Subject: [PATCH] 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 --- .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 9 +++- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 18 +++---- .../Circles/CirclesNestingEngine.cs | 51 +++++++++++++++++-- docs/nesting-engines.md | 14 ++--- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 2a777b4b..743b4b41 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -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(StringComparer.Ordinal); var byFrame = new Dictionary>(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() : 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); } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index b35f6125..a0ef718f 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -9,8 +9,8 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. /// 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. 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(); 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(); diff --git a/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs index ebf5d384..6d449eef 100644 --- a/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs @@ -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 is kept. +/// preferring each offered stock, and the best result by 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. /// 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()); - 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 Pack(NestPlateStock stock, IReadOnlyList left) { if (!types.Any(t => left[t.Index] > 0 && DiscPacker.Fits(stock, t))) diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index ad9dd3ce..f612dd7d 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -13,7 +13,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; 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