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.