From dd2c00aed47704ea7b521bdbc94d47ee7ce7952b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 15:36:02 -0400 Subject: [PATCH] perf(engine): replay cutout pre-pass clearance checks and trim hole subtraction Profiling the cutout pre-pass on real round-part jobs put ~97% of its time in the frame-wall NestLayoutCheck.Clears inside CutoutCirclePacker.Fill. Ear-clipped round frames make every perimeter/insert triangle pair overlap, so a clear pair runs hole subtraction for every pair. The same question repeats for every frame angle and repeated frame instance, because Automatic inserts get identical frame-local poses. - CutoutMemo, one per Prepare, replays geometry reads, circle items and Clears answers. Keys hold geometry by reference, pose bit patterns, argument order and spacing. It is threaded through CutoutCirclePacker.Fill and the router's round-only path. - TriangulatedRegion.SubtractAllHoles measures each piece's bounds once, skips holes and hole triangles that no piece reaches, and ping-pongs the remainder between scratch buffers instead of copying it into pooled ones. Clip arithmetic and order are unchanged. Bundles are byte-identical on 14 real and 10 synthetic ring jobs. Pre-pass time over the real set drops from 99.6 s to 10.1 s (N0414-02 35.8 s -> 0.6 s). Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/TriangulatedRegion.cs | 102 ++++++++++++------ .../Jobs/Cutouts/CutoutCirclePacker.cs | 25 +++-- OpenNest.Engine/Jobs/Cutouts/CutoutMemo.cs | 59 ++++++++++ .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 11 +- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 18 +++- docs/nesting-engines.md | 5 +- 6 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutMemo.cs diff --git a/OpenNest.Core/Geometry/TriangulatedRegion.cs b/OpenNest.Core/Geometry/TriangulatedRegion.cs index 42343ece..7919e4c6 100644 --- a/OpenNest.Core/Geometry/TriangulatedRegion.cs +++ b/OpenNest.Core/Geometry/TriangulatedRegion.cs @@ -264,6 +264,9 @@ namespace OpenNest.Geometry [ThreadStatic] private static double[]? s_tmpB; + [ThreadStatic] + private static double[]? s_tmpC; + private static double[] AcquireBuffer() { var pool = s_pool ??= new List(); @@ -282,11 +285,12 @@ namespace OpenNest.Geometry pool.Add(buf); } - private static (double[] Tmp, double[] Inside) ScratchPair() + private static (double[] Tmp, double[] Inside, double[] Spare) Scratch() { s_tmpA ??= new double[MaxClipVertices * 2]; s_tmpB ??= new double[MaxClipVertices * 2]; - return (s_tmpA, s_tmpB); + s_tmpC ??= new double[MaxClipVertices * 2]; + return (s_tmpA, s_tmpB, s_tmpC); } private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles( @@ -309,9 +313,21 @@ namespace OpenNest.Geometry return (false, false, false); // pieces[0] is the caller's own buffer - never release it back to the pool. - var pieces = new List<(double[] Buf, int Count)> { (piece, count) }; + // A piece's buffer never changes once listed, so its bbox is measured once. + var pieces = new List { Measure(piece, count) }; + var spareList = new List(); var owned = new HashSet(); + // Built-in uses <=: touching skips subtraction, and a hole triangle no piece + // reaches passes every piece on unchanged, so it is skipped without rebuilding. + bool AnyReaches(double hMinX, double hMinY, double hMaxX, double hMaxY) + { + foreach (var p in pieces) + if (!(p.MaxX <= hMinX || hMaxX <= p.MinX || p.MaxY <= hMinY || hMaxY <= p.MinY)) + return true; + return false; + } + bool SubtractOwner(TriangulatedRegion owner, double odx, double ody) { if (owner.Holes == null) @@ -321,41 +337,32 @@ namespace OpenNest.Geometry var hole = owner.Holes[h]; if (hole == null) continue; // untriangulatable hole: nothing to subtract + // Every triangle's bbox lies within the hole's, translated alike. + if (!AnyReaches(hole.MinX + odx, hole.MinY + ody, hole.MaxX + odx, hole.MaxY + ody)) + continue; for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++) { var hMinX = hole._tMinX[t] + odx; var hMaxX = hole._tMaxX[t] + odx; var hMinY = hole._tMinY[t] + ody; var hMaxY = hole._tMaxY[t] + ody; + if (!AnyReaches(hMinX, hMinY, hMaxX, hMaxY)) + continue; - var next = new List<(double[], int)>(); + var next = spareList; + next.Clear(); for (var p = 0; p < pieces.Count; p++) { - var (buf, pc) = pieces[p]; + var current = pieces[p]; + var buf = current.Buf; + var pc = current.Count; - // Piece bbox (built-in uses <=: touching skips subtraction). - var pMinX = double.MaxValue; - var pMinY = double.MaxValue; - var pMaxX = double.MinValue; - var pMaxY = double.MinValue; - for (var v = 0; v < pc; v++) - { - var px = buf[v * 2]; - var py = buf[v * 2 + 1]; - if (px < pMinX) - pMinX = px; - if (px > pMaxX) - pMaxX = px; - if (py < pMinY) - pMinY = py; - if (py > pMaxY) - pMaxY = py; - } - if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY) + if (current.MaxX <= hMinX || hMaxX <= current.MinX + || current.MaxY <= hMinY || hMaxY <= current.MinY) { if (next.Count >= MaxPieces) return false; - next.Add((buf, pc)); + next.Add(current); continue; } @@ -363,11 +370,11 @@ namespace OpenNest.Geometry // outside of each edge survives as its own piece; the inside // remainder continues into the next edge. The remainder inside // all three edges is consumed (the hole ate it). - var rem = AcquireBuffer(); - owned.Add(rem); - Array.Copy(buf, rem, pc * 2); + // The remainder reads the piece itself first, then alternates + // between two scratch buffers; it is never kept. + var rem = buf; var remCount = pc; - var (tmp, insideBuf) = ScratchPair(); + var (tmp, insideBuf, spareBuf) = Scratch(); for (var e = 0; e < 3 && remCount >= 3; e++) { var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t]; @@ -388,20 +395,20 @@ namespace OpenNest.Geometry var keep = AcquireBuffer(); owned.Add(keep); Array.Copy(tmp, keep, outCount * 2); - next.Add((keep, outCount)); + next.Add(Measure(keep, outCount)); } remCount = ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf); if (remCount >= MaxClipVertices) return false; // undecided - Array.Copy(insideBuf, rem, remCount * 2); + rem = insideBuf; + (insideBuf, spareBuf) = (spareBuf, insideBuf); } // The inside-all-edges remainder is consumed by the hole: drop it. - owned.Remove(rem); - ReleaseBuffer(rem); if (owned.Remove(buf)) ReleaseBuffer(buf); } + spareList = pieces; pieces = next; } } @@ -413,8 +420,8 @@ namespace OpenNest.Geometry if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy)) return (true, true, false); - foreach (var (buf, pc) in pieces) - if (pc >= 3 && TwiceArea(buf, pc) > areaFloor) + foreach (var p in pieces) + if (p.Count >= 3 && TwiceArea(p.Buf, p.Count) > areaFloor) return (true, false, true); return (true, false, false); } @@ -425,6 +432,31 @@ namespace OpenNest.Geometry } } + private readonly record struct Piece(double[] Buf, int Count, double MinX, double MinY, + double MaxX, double MaxY); + + private static Piece Measure(double[] buf, int count) + { + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + for (var v = 0; v < count; v++) + { + var px = buf[v * 2]; + var py = buf[v * 2 + 1]; + if (px < minX) + minX = px; + if (px > maxX) + maxX = px; + if (py < minY) + minY = py; + if (py > maxY) + maxY = py; + } + return new Piece(buf, count, minX, minY, maxX, maxY); + } + /// Clip this' triangle against other's triangle; returns count into piece. private int ClipTriangle( int ta, diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs index f83383a3..15692c5f 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutCirclePacker.cs @@ -70,14 +70,21 @@ internal static class CutoutCirclePacker /// Frame-local poses inside one round cutout, or none when the cutout is not round /// or nothing fits. Instance indices count per requirement from zero. internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, - IReadOnlyList inserts, double spacing, CancellationToken token = default) + IReadOnlyList inserts, double spacing, CancellationToken token = default) => + Fill(frame, cutoutIndex, inserts, spacing, null, token); + + /// Same proposal; a pre-pass passes its to replay geometry + /// reads and clearance checks it has already made. + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CutoutMemo? memo, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(inserts); token.ThrowIfCancellationRequested(); if (!double.IsFinite(spacing) || spacing < 0) return Array.Empty(); - var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + var frameGeometry = Read(frame.Geometry); if (frameGeometry == null) return Array.Empty(); ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); @@ -93,10 +100,10 @@ internal static class CutoutCirclePacker foreach (var part in inserts) { token.ThrowIfCancellationRequested(); - var geometry = JobPartGeometry.TryRead(part.Geometry); + var geometry = Read(part.Geometry); if (geometry == null || part.Quantity <= 0) continue; - var item = Item.Create(geometry); + var item = memo?.ItemOf(geometry) ?? Item.Create(geometry); var rotations = part.Rotation.EnumerateAngles(maxSamples: 1); if (rotations.Count == 0 || item.Radius > wall) continue; @@ -127,18 +134,24 @@ internal static class CutoutCirclePacker var pose = new NestJobPlacement(disc.Part.Id, accepted.Count(p => p.Pose.PartId == disc.Part.Id), origin.X, origin.Y, disc.Rotation); var geometry = geometries[disc.Part.Id]; - if (!NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing)) + if (!Clears(frameGeometry, framePose, geometry, pose)) continue; // Discs farther apart than their radii plus twice the spacing cannot touch the // check's inflated outlines; the analytic gap already proves them clear. if (accepted.Any(other => other.Center.DistanceTo(center) < other.Disc.Radius + disc.Radius + 2 * spacing + Margin - && !NestLayoutCheck.Clears(geometries[other.Disc.Part.Id], other.Pose, geometry, pose, spacing))) + && !Clears(geometries[other.Disc.Part.Id], other.Pose, geometry, pose))) continue; accepted.Add((disc, center, pose)); } token.ThrowIfCancellationRequested(); return accepted.Select(p => p.Pose).ToArray(); + + JobPartGeometry? Read(PartGeometrySnapshot snapshot) => + memo != null ? memo.Read(snapshot) : JobPartGeometry.TryRead(snapshot); + + bool Clears(JobPartGeometry a, NestJobPlacement pa, JobPartGeometry b, NestJobPlacement pb) => + memo?.Clears(a, pa, b, pb, spacing) ?? NestLayoutCheck.Clears(a, pa, b, pb, spacing); } /// Places each disc in order at its best feasible candidate; a disc with no diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutMemo.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutMemo.cs new file mode 100644 index 00000000..b4a5c9f4 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutMemo.cs @@ -0,0 +1,59 @@ +#nullable enable +using System; +using System.Collections.Generic; +using OpenNest.Engine.CirclePacking; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Exact replays of pure cutout computations within one pre-pass, which asks the same +/// questions for every frame instance and frame angle. A clear frame and insert cost +/// its worst case: every perimeter triangle pair, then every +/// hole triangle. Not thread-safe; one instance per call. +/// Clearance keys hold both geometries by reference, their poses bit for bit, the +/// argument order and the spacing, so a replay is the answer the check would give. Geometry is +/// read once per snapshot; shared readers must clone before transforming, as they already do. +internal sealed class CutoutMemo +{ + private readonly record struct Key(JobPartGeometry A, long AX, long AY, long ARotation, + JobPartGeometry B, long BX, long BY, long BRotation, long Spacing); + + private readonly Dictionary clearances = new(); + private readonly Dictionary geometries = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary items = new(); + + internal JobPartGeometry? Read(PartGeometrySnapshot snapshot) + { + if (!geometries.TryGetValue(snapshot, out var geometry)) + { + geometry = JobPartGeometry.TryRead(snapshot); + geometries.Add(snapshot, geometry); + } + return geometry; + } + + internal Item ItemOf(JobPartGeometry geometry) + { + if (!items.TryGetValue(geometry, out var item)) + { + item = Item.Create(geometry); + items.Add(geometry, item); + } + return item; + } + + internal bool Clears(JobPartGeometry a, NestJobPlacement pa, + JobPartGeometry b, NestJobPlacement pb, double spacing) + { + var key = new Key(a, Bits(pa.X), Bits(pa.Y), Bits(pa.Rotation), + b, Bits(pb.X), Bits(pb.Y), Bits(pb.Rotation), Bits(spacing)); + if (!clearances.TryGetValue(key, out var clears)) + { + clears = NestLayoutCheck.Clears(a, pa, b, pb, spacing); + clearances.Add(key, clears); + } + return clears; + } + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 743b4b41..ef900814 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -46,13 +46,14 @@ internal sealed class CutoutPipelinePrepass // One reservation vector works for every offered stock. Current jobs cannot // represent stock-conditional insert quantities; use the largest spacing. var spacing = original.Plates.Max(p => p.PartSpacing); + var memo = new CutoutMemo(); var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity); - var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry)); + var geometry = original.Parts.ToDictionary(p => p.Id, p => memo.Read(p.Geometry)); // Proxies join these as they are built, so a larger frame can take one as an insert. var offers = original.Parts.ToList(); 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); + && memo.ItemOf(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); @@ -109,7 +110,7 @@ internal sealed class CutoutPipelinePrepass token.ThrowIfCancellationRequested(); var source = geometry[pose.PartId]!; if (!requirements[pose.PartId].Rotation.Allows(angle + pose.Rotation) - || local.Any(other => !NestLayoutCheck.Clears( + || local.Any(other => !memo.Clears( geometry[other.PartId]!, other, source, pose, spacing))) continue; local.Add(pose); @@ -164,13 +165,13 @@ internal sealed class CutoutPipelinePrepass IReadOnlyList poses = Array.Empty(); if (nested.Length > 0) poses = CutoutCirclePacker.Fill(frame, hole, - flat.Where(p => round[p.Id]).Concat(nested).ToArray(), spacing, token); + flat.Where(p => round[p.Id]).Concat(nested).ToArray(), spacing, memo, token); // Seating every flat copy as a disc leaves the slower proposals nothing to beat. var flatIds = flat.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); if (flat.Length == 0 || poses.Count(p => flatIds.Contains(p.PartId)) == flat.Sum(p => (long)p.Quantity)) return poses; - var router = CutoutRouter.Fill(frame, hole, flat, spacing, token); + var router = CutoutRouter.Fill(frame, hole, flat, spacing, memo, token); return Weight(router) >= Weight(poses) ? router : poses; } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index a0ef718f..2e4dbc08 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -22,13 +22,25 @@ internal static class CutoutRouter internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, IReadOnlyList inserts, double spacing, CancellationToken token = default) => - Fill(frame, cutoutIndex, inserts, spacing, null, token); + Fill(frame, cutoutIndex, inserts, spacing, null, null, token); + + /// Same proposal; a pre-pass passes its so circle-packed + /// holes replay geometry reads and clearance checks it has already made. + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CutoutMemo memo, + CancellationToken token = default) => + Fill(frame, cutoutIndex, inserts, spacing, null, memo, token); /// Optional internal diagnostic receives only poses from the chosen proposal, /// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose. internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, IReadOnlyList inserts, double spacing, - Action? observeAccepted, CancellationToken token = default) + Action? observeAccepted, CancellationToken token = default) => + Fill(frame, cutoutIndex, inserts, spacing, observeAccepted, null, token); + + private static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, + Action? observeAccepted, CutoutMemo? memo, CancellationToken token) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(inserts); @@ -54,7 +66,7 @@ internal static class CutoutRouter // 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)); + return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token)); var accepted = new List(); var observed = new List<(NestJobPlacement Pose, bool Lattice)>(); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index f612dd7d..fcad555a 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -276,7 +276,10 @@ plates even with invalid-result consent. Candidate evaluation progress is forwar with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical commits are reported only after physical validation. Preparation is bounded to 32 bundles and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained); -remaining copies stay ordinary requirements. Fixed legal proxy orientations are a +remaining copies stay ordinary requirements. Within one preparation, geometry reads and +`NestLayoutCheck.Clears` answers are replayed (`CutoutMemo`: same geometry, bit-identical poses), +so repeated frame instances and frame angles do not repeat that check, its main cost; layouts are +the same as without the replay. Fixed legal proxy orientations are a conservative search choice, not full engine choice among all legal orientations; unsupported bundles remain ordinary independent requirements.