From bcbcf1e611a8fa76ac4ca483efabc940cf36e0aa Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Nov 2026 19:09:14 -0500 Subject: [PATCH] perf(fill): cut a cold Lattice fill from ~300 s to ~160 s with identical layouts The first fill of a new drawing was blamed on the best-fit pair search, but on the reported nest that search takes ~3.4 s. The time goes to FillLinear.FillGrid, run thousands of times by the Pairs, Row/Column and Linear strategies: pattern copy-distance slides and the grid overlap check. Every change keeps the output bit for bit (part poses compared on the benchmark nest; best-fit results unchanged). - ConvexDecomposition.Triangulate: unlink clipped vertices from a circular list instead of List.RemoveAt, and remember why a vertex failed the ear test (same neighbors, same blocking vertex still present) instead of retesting it after every ear. Compared bitwise against a frozen pre-change copy, including the blocker-clipped case. - Collision: skip triangles whose box misses the other triangulation's union box (exact: min/max and monotone subtraction), reuse each triangle's open vertices, and let the overlap-only path stop at its first region when there are no holes. Check still returns every region. - SpatialQuery.ExtractEntityVertices presizes its set; FillLinear extracts each entity list's vertices once per copy-distance search rather than once per pair. - StripeFiller converges its stripe angles and tiles its grids in parallel. Dedup claims, cached remnant fills, ranking and progress stay in candidate order, and grids are tiled only for configurations a claim can still win (GridDedup.Contains), so results and reports do not depend on the thread count. - LinearFillStrategy fills its angles in parallel through FillHelpers.BestOverAnglesInParallel; angle results, ranking and progress stay in order. Benchmark (one drawing, 60x120, 0.25 spacing, cleared plate, interleaved runs): FillItem Lattice 291/317 s -> 164/153 s, 132 parts both; PairFiller alone 48 s -> 38-42 s. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/Collision.cs | 135 +++++++++-- OpenNest.Core/Geometry/ConvexDecomposition.cs | 89 ++++++-- OpenNest.Core/Geometry/SpatialQuery.cs | 32 ++- OpenNest.Engine/Fill/FillLinear.cs | 21 +- OpenNest.Engine/Fill/GridDedup.cs | 7 + OpenNest.Engine/Fill/StripeFiller.cs | 204 ++++++++++++----- OpenNest.Engine/Strategies/FillHelpers.cs | 49 ++++ .../Strategies/LinearFillStrategy.cs | 11 +- .../ConvexDecompositionEquivalenceTests.cs | 210 ++++++++++++++++++ .../Geometry/LegacyConvexDecomposition.cs | 167 ++++++++++++++ 10 files changed, 816 insertions(+), 109 deletions(-) create mode 100644 OpenNest.Tests/Geometry/ConvexDecompositionEquivalenceTests.cs create mode 100644 OpenNest.Tests/Geometry/LegacyConvexDecomposition.cs diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 2c9c2fdc..fe60a369 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -58,7 +58,7 @@ namespace OpenNest.Geometry // Clipping decides the verdict, including containment (one polygon entirely // inside another has zero edge crossings). Crossing points never affect it, // so this overlap-only path skips them. - return OverlapRegions(a, b, holesA, holesB).Count > 0; + return OverlapRegions(a, b, holesA, holesB, verdictOnly: true).Count > 0; } /// @@ -79,7 +79,7 @@ namespace OpenNest.Geometry if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) return false; - return OverlapRegions(trianglesA(), trianglesB(), holesA, holesB).Count > 0; + return OverlapRegions(trianglesA(), trianglesB(), holesA, holesB, verdictOnly: true).Count > 0; } /// @@ -138,30 +138,26 @@ namespace OpenNest.Geometry Polygon a, Polygon b, List holesA, - List holesB - ) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB); + List holesB, + bool verdictOnly = false + ) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB, verdictOnly); private static List OverlapRegions( List trisA, List trisB, List holesA, - List holesB + List holesB, + bool verdictOnly = false ) { var regions = new List(); - foreach (var triA in trisA) - { - foreach (var triB in trisB) - { - if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox)) - continue; - - var clipped = ClipConvex(triA, triB); - if (clipped != null) - regions.Add(clipped); - } - } + // Without holes nothing is subtracted, so for a caller that needs only the verdict + // the first clipped region decides it. Check returns the regions, so it gets all. + var stopAtFirst = verdictOnly + && (holesA == null || holesA.Count == 0) + && (holesB == null || holesB.Count == 0); + ClipTrianglePairs(trisA, trisB, regions, stopAtFirst); if (regions.Count > 0) regions = SubtractHoles(regions, holesA, holesB); @@ -169,6 +165,106 @@ namespace OpenNest.Geometry return regions; } + /// + /// Clips every triangle pair whose bounding boxes overlap, in (A, B) order, adding the + /// positive-area regions. A triangle whose box misses the union of the other list's + /// boxes overlaps none of them (min/max are exact and subtraction is monotone, so a + /// pair's overlap never exceeds the overlap with the union), so it is dropped before + /// the pair loop. The pairs clipped, their order and the regions are those of the + /// plain double loop. + /// + private static void ClipTrianglePairs( + List trisA, + List trisB, + List regions, + bool stopAtFirst + ) + { + if (trisA.Count == 0 || trisB.Count == 0) + return; + + var candidatesA = TrianglesTouching(trisA, UnionBounds(trisB)); + if (candidatesA.Count == 0) + return; + + var candidatesB = TrianglesTouching(trisB, UnionBounds(trisA)); + if (candidatesB.Count == 0) + return; + + // The clip loop only reads its inputs, so each triangle's open vertices serve + // every pair it is in. + var openB = new List[candidatesB.Count]; + for (var j = 0; j < candidatesB.Count; j++) + openB[j] = OpenVertices(candidatesB[j]); + + foreach (var triA in candidatesA) + { + List openA = null; + + for (var j = 0; j < candidatesB.Count; j++) + { + var triB = candidatesB[j]; + if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox)) + continue; + + openA ??= OpenVertices(triA); + var clipped = ClipConvex(openA, openB[j]); + if (clipped == null) + continue; + + regions.Add(clipped); + if (stopAtFirst) + return; + } + } + } + + private static (double Left, double Right, double Bottom, double Top) UnionBounds( + List polygons + ) + { + var left = double.PositiveInfinity; + var right = double.NegativeInfinity; + var bottom = double.PositiveInfinity; + var top = double.NegativeInfinity; + + foreach (var polygon in polygons) + { + var box = polygon.BoundingBox; + left = System.Math.Min(left, box.Left); + right = System.Math.Max(right, box.Right); + bottom = System.Math.Min(bottom, box.Bottom); + top = System.Math.Max(top, box.Top); + } + + return (left, right, bottom, top); + } + + /// + /// The polygons whose boxes pass against + /// , in order. Same arithmetic as that test, with the union's + /// sides in place of the other box's. + /// + private static List TrianglesTouching( + List polygons, + (double Left, double Right, double Bottom, double Top) union + ) + { + var result = new List(polygons.Count); + + foreach (var polygon in polygons) + { + var box = polygon.BoundingBox; + var overlapX = System.Math.Min(box.Right, union.Right) - System.Math.Max(box.Left, union.Left); + var overlapY = System.Math.Min(box.Top, union.Top) - System.Math.Max(box.Bottom, union.Bottom); + + if (overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon) + result.Add(polygon); + } + + return result; + } + private static bool BoundingBoxesOverlap(Box a, Box b) { var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left); @@ -232,10 +328,9 @@ namespace OpenNest.Geometry /// Sutherland-Hodgman polygon clipping. Clips subject against each edge /// of clip. Both must be convex. Returns null if no overlap. /// - private static Polygon ClipConvex(Polygon subject, Polygon clip) + private static Polygon ClipConvex(List subjectVerts, List clipVerts) { - var output = OpenVertices(subject); - var clipVerts = OpenVertices(clip); + var output = subjectVerts; for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++) { output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true); diff --git a/OpenNest.Core/Geometry/ConvexDecomposition.cs b/OpenNest.Core/Geometry/ConvexDecomposition.cs index 6c3e0781..bfb752e9 100644 --- a/OpenNest.Core/Geometry/ConvexDecomposition.cs +++ b/OpenNest.Core/Geometry/ConvexDecomposition.cs @@ -33,32 +33,57 @@ namespace OpenNest.Geometry if (SignedArea(verts) < 0) verts.Reverse(); - // Build a linked list of vertex indices. - var indices = new List(verts.Count); + // The remaining vertices, in their original order, as a circular linked list: head is + // the first remaining vertex (scan position 0), and cutting an ear unlinks one vertex + // instead of shifting every later index down. + var count = verts.Count; + var nextOf = new int[count]; + var prevOf = new int[count]; + for (var i = 0; i < count; i++) + { + nextOf[i] = (i + 1) % count; + prevOf[i] = (i + count - 1) % count; + } - for (var i = 0; i < verts.Count; i++) - indices.Add(i); + var head = 0; + var n = count; - var n = indices.Count; + // The scan restarts at the first remaining vertex after every ear. A vertex that + // failed the ear test keeps failing it while its two neighbors are unchanged and + // the vertex found inside its triangle (or none, when it was not convex) remains, + // since the test depends on nothing else. Remembering why it failed skips repeating + // the O(n) test; the vertices visited, their verdicts and the ears cut are the same. + var removed = new bool[count]; + var failedPrev = new int[count]; + var failedNext = new int[count]; + var failedBlocker = new int[count]; + for (var i = 0; i < count; i++) + failedPrev[i] = -1; // Safety counter to avoid infinite loop on degenerate polygons. var maxIterations = n * n; var iterations = 0; - var i0 = 0; + var cursor = head; while (n > 2 && iterations < maxIterations) { iterations++; - var prevIdx = (i0 + n - 1) % n; - var currIdx = i0 % n; - var nextIdx = (i0 + 1) % n; + var currVertex = cursor; + var prevVertex = prevOf[currVertex]; + var nextVertex = nextOf[currVertex]; - var prev = verts[indices[prevIdx]]; - var curr = verts[indices[currIdx]]; - var next = verts[indices[nextIdx]]; + var knownNotEar = + failedPrev[currVertex] == prevVertex + && failedNext[currVertex] == nextVertex + && (failedBlocker[currVertex] < 0 || !removed[failedBlocker[currVertex]]); - if (IsEar(prev, curr, next, verts, indices, n)) + var prev = verts[prevVertex]; + var curr = verts[currVertex]; + var next = verts[nextVertex]; + + var blocker = failedBlocker[currVertex]; + if (!knownNotEar && IsEar(prev, curr, next, verts, nextOf, head, n, out blocker)) { var tri = new Polygon(); tri.Vertices.Add(prev); @@ -67,16 +92,25 @@ namespace OpenNest.Geometry tri.Close(); triangles.Add(tri); - indices.RemoveAt(currIdx); + nextOf[prevVertex] = nextVertex; + prevOf[nextVertex] = prevVertex; + removed[currVertex] = true; + if (head == currVertex) + head = nextVertex; n--; - i0 = 0; + cursor = head; } else { - i0++; + if (!knownNotEar) + { + failedPrev[currVertex] = prevVertex; + failedNext[currVertex] = nextVertex; + failedBlocker[currVertex] = blocker; + } - if (i0 >= n) - i0 = 0; + // The last remaining vertex links back to head, as position n wraps to 0. + cursor = nextVertex; } } @@ -85,25 +119,31 @@ namespace OpenNest.Geometry /// /// Tests whether the vertex at curr forms an ear (a convex vertex whose - /// triangle contains no other polygon vertices). + /// triangle contains no other polygon vertices). A vertex that fails reports the + /// first remaining vertex found inside its triangle, or -1 when it is not convex. /// private static bool IsEar( Vector prev, Vector curr, Vector next, List verts, - List indices, - int n + int[] nextOf, + int head, + int n, + out int blocker ) { + // The vertex found inside the triangle, or -1 when the vertex is not convex. + blocker = -1; + // Must be convex (CCW turn). if (Cross(prev, curr, next) <= 0) return false; // Check that no other vertex lies inside the triangle. - for (var i = 0; i < n; i++) + for (int i = 0, index = head; i < n; i++, index = nextOf[index]) { - var v = verts[indices[i]]; + var v = verts[index]; if (v.X == prev.X && v.Y == prev.Y) continue; @@ -113,7 +153,10 @@ namespace OpenNest.Geometry continue; if (PointInTriangle(v, prev, curr, next)) + { + blocker = index; return false; + } } return true; diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs index 17d854b5..5c1e1d81 100644 --- a/OpenNest.Core/Geometry/SpatialQuery.cs +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -743,16 +743,40 @@ namespace OpenNest.Geometry Vector direction, SlideContactClassifier contacts ) + { + return DirectionalDistance( + movingEntities, + ExtractEntityVertices(movingEntities), + stationaryEntities, + ExtractEntityVertices(stationaryEntities), + direction, + contacts + ); + } + + /// + /// + /// with each list's result supplied by a caller that + /// slides one list against several others. The vertices must come from these exact lists. + /// + public static double DirectionalDistance( + List movingEntities, + Vector[] movingVertices, + List stationaryEntities, + Vector[] stationaryVertices, + Vector direction, + SlideContactClassifier contacts + ) { // Phases: vertex rays both ways, arc-to-line closest points (vertex sampling // misses interior arc contact), then native curve tangency. var source = new EntitySlideEvents( movingEntities, - ExtractEntityVertices(movingEntities), + movingVertices, 0, 0, stationaryEntities, - ExtractEntityVertices(stationaryEntities), + stationaryVertices, direction.X, direction.Y, arcToLine: true @@ -762,7 +786,9 @@ namespace OpenNest.Geometry public static Vector[] ExtractEntityVertices(List entities) { - var vertices = new HashSet(); + // Presized: a set without removals enumerates in insertion order whatever its + // capacity, so this only saves the rehashes of growing from empty. + var vertices = new HashSet(entities.Count * 2); for (var i = 0; i < entities.Count; i++) { diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index 4d837f37..fb9a94a6 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -148,6 +148,9 @@ namespace OpenNest.Engine.Fill var movingBoxes = new Box[parts.Count]; var stationaryEntities = new List[parts.Count]; var movingEntities = new List[parts.Count]; + // Each list slides against several others; extract its vertices once. + var stationaryVertices = new Vector[parts.Count][]; + var movingVertices = new Vector[parts.Count][]; for (var i = 0; i < parts.Count; i++) { @@ -174,13 +177,25 @@ namespace OpenNest.Engine.Fill if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec)) continue; - stationaryEntities[i] ??= cache.AtLocation(parts[i].Program, parts[i].Location); - movingEntities[j] ??= cache.AtLocation(parts[j].Program, parts[j].Location + offset); + if (stationaryEntities[i] == null) + { + stationaryEntities[i] = cache.AtLocation(parts[i].Program, parts[i].Location); + stationaryVertices[i] = SpatialQuery.ExtractEntityVertices(stationaryEntities[i]); + } + + if (movingEntities[j] == null) + { + movingEntities[j] = cache.AtLocation(parts[j].Program, parts[j].Location + offset); + movingVertices[j] = SpatialQuery.ExtractEntityVertices(movingEntities[j]); + } var slideDistance = SpatialQuery.DirectionalDistance( movingEntities[j], + movingVertices[j], stationaryEntities[i], - pushDir + stationaryVertices[i], + dirVec, + new SlideContactClassifier(movingEntities[j], stationaryEntities[i]) ); if (slideDistance >= double.MaxValue || slideDistance < 0) diff --git a/OpenNest.Engine/Fill/GridDedup.cs b/OpenNest.Engine/Fill/GridDedup.cs index 70d9517a..2d16322d 100644 --- a/OpenNest.Engine/Fill/GridDedup.cs +++ b/OpenNest.Engine/Fill/GridDedup.cs @@ -24,6 +24,13 @@ public class GridDedup return _seen.TryAdd(key, 0); } + /// + /// True if already accepted this configuration. Claims are never + /// removed, so a configuration seen now stays rejected. + /// + public bool Contains(Box patternBox, Box workArea, NestDirection dir) => + _seen.ContainsKey(new GridKey(patternBox, workArea, dir)); + public int Count => _seen.Count; /// diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs index 15837b39..47dfd17e 100644 --- a/OpenNest.Engine/Fill/StripeFiller.cs +++ b/OpenNest.Engine/Fill/StripeFiller.cs @@ -1,7 +1,10 @@ +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.ExceptionServices; using System.Threading; +using System.Threading.Tasks; using OpenNest.Engine; using OpenNest.Engine.BestFit; using OpenNest.Engine.Strategies; @@ -41,58 +44,36 @@ public class StripeFiller return new List(); var workArea = _context.WorkArea; - var spacing = _context.Plate.PartSpacing; var drawing = _context.Item.Drawing; var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column"; + var layouts = ComputeLayouts(bestFits, drawing); + List bestParts = null; for (var i = 0; i < bestFits.Count; i++) { _context.Token.ThrowIfCancellationRequested(); - var candidate = bestFits[i]; - var pairParts = candidate.BuildParts(drawing); - - foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical }) + for (var a = 0; a < Axes.Length; a++) { - var perpAxis = - axis == NestDirection.Horizontal - ? NestDirection.Vertical - : NestDirection.Horizontal; - var sheetSpan = GetDimension(workArea, axis); + var axis = Axes[a]; var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col"; - var expandResult = ConvergeStripeAngle( - pairParts, - sheetSpan, - spacing, - axis, - _context.Token, - _context.MaxQuantity - ); - var shrinkResult = ConvergeStripeAngleShrink( - pairParts, - sheetSpan, - spacing, - axis, - _context.Token, - _context.MaxQuantity - ); - - foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult }) + for (var v = 0; v < VariantsPerAxis; v++) { - if (count <= 0) + var layout = layouts[LayoutIndex(i, a, v)]; + if (layout.Count <= 0) continue; - var result = BuildGrid(pairParts, angle, axis, perpAxis); + var result = ClaimGrid(layout, axis); if (result == null || result.Count == 0) continue; Debug.WriteLine( $"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " - + $"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " + + $"angle={Angle.ToDegrees(layout.Angle):F1}°, N={layout.Count}, waste={layout.Waste:F2}, " + $"grid={result.Count} parts" ); @@ -112,23 +93,149 @@ public class StripeFiller return bestParts ?? new List(); } - private List BuildGrid( - List pairParts, - double angle, - NestDirection primaryAxis, - NestDirection perpAxis - ) + private static readonly NestDirection[] Axes = { NestDirection.Horizontal, NestDirection.Vertical }; + + /// Expanding () then shrinking convergence. + private const int VariantsPerAxis = 2; + + private static int LayoutIndex(int candidate, int axis, int variant) => + (candidate * Axes.Length + axis) * VariantsPerAxis + variant; + + /// + /// A converged stripe angle and, when it holds a pattern, the rotated pattern and the grid + /// it tiles before the order-dependent steps ( claim, cached remnant + /// fill). + /// + private sealed class StripeLayout { + public double Angle; + public double Waste; + public int Count; + public Pattern Pattern; + public bool GridReady; + public List Grid; + } + + /// + /// Converges every candidate's stripe angle per axis and variant, then tiles the grids that + /// will claim, each step in parallel. Every layout is a pure function of + /// its candidate, axis and variant (fresh part clones, per-call + /// state), so the results do not depend on the thread count. A grid is tiled only when its + /// configuration is not yet claimed and no earlier layout shares it: claims are only ever + /// added, so exactly those are the grids the sequential loop would have tiled. + /// then claims, fills remnants and ranks in the original order. + /// + private StripeLayout[] ComputeLayouts(List bestFits, Drawing drawing) + { + var layouts = new StripeLayout[bestFits.Count * Axes.Length * VariantsPerAxis]; + + RunParallel(layouts.Length, index => + { + var variant = index % VariantsPerAxis; + var axis = Axes[index / VariantsPerAxis % Axes.Length]; + var candidate = bestFits[index / VariantsPerAxis / Axes.Length]; + layouts[index] = Converge(candidate.BuildParts(drawing), axis, variant); + }); + + var batch = new GridDedup(); + var toTile = new List(); + for (var index = 0; index < layouts.Length; index++) + { + var layout = layouts[index]; + if (layout.Count <= 0) + continue; + + var axis = Axes[index / VariantsPerAxis % Axes.Length]; + var box = layout.Pattern.BoundingBox; + if (!_dedup.Contains(box, _context.WorkArea, axis) && batch.TryAdd(box, _context.WorkArea, axis)) + toTile.Add(index); + } + + RunParallel(toTile.Count, k => + { + var index = toTile[k]; + var layout = layouts[index]; + layout.Grid = BuildGrid(layout.Pattern, Axes[index / VariantsPerAxis % Axes.Length]); + layout.GridReady = true; + }); + + return layouts; + } + + private void RunParallel(int count, Action body) + { + try + { + Parallel.For(0, count, new ParallelOptions { CancellationToken = _context.Token }, body); + } + catch (AggregateException ex) + { + // Surface the failure itself, cancellation included, as the sequential loop did. + ExceptionDispatchInfo.Capture(ex.InnerExceptions[0]).Throw(); + throw; + } + } + + private StripeLayout Converge(List pairParts, NestDirection axis, int variant) + { + var spacing = _context.Plate.PartSpacing; + var sheetSpan = GetDimension(_context.WorkArea, axis); + + var (angle, waste, count) = variant == 0 + ? ConvergeStripeAngle(pairParts, sheetSpan, spacing, axis, _context.Token, _context.MaxQuantity) + : ConvergeStripeAngleShrink(pairParts, sheetSpan, spacing, axis, _context.Token, _context.MaxQuantity); + + var layout = new StripeLayout { Angle = angle, Waste = waste, Count = count }; + if (count > 0) + layout.Pattern = FillHelpers.BuildRotatedPattern(pairParts, angle); + + return layout; + } + + /// + /// The order-dependent half of a stripe grid: claims its configuration and adds the cached + /// remnant fill. Null when the configuration was already evaluated or no grid fits. + /// + private List ClaimGrid(StripeLayout layout, NestDirection primaryAxis) + { + if (!_dedup.TryAdd(layout.Pattern.BoundingBox, _context.WorkArea, primaryAxis)) + return null; + + // ComputeLayouts tiled every grid a claim can succeed for; tile here should it not have. + var gridParts = layout.GridReady ? layout.Grid : BuildGrid(layout.Pattern, primaryAxis); + if (gridParts == null) + return null; + + var allParts = new List(gridParts); + + // A capped fill that already holds the quantity needs no remnant parts. + if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity) + return allParts; + + var remnantParts = FillRemnant(gridParts, primaryAxis); + if (remnantParts != null) + { + Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts"); + allParts.AddRange(remnantParts); + } + + return allParts; + } + + /// + /// Tiles the rotated pattern into a stripe and the stripe into a grid. Pure: reads only the + /// pattern and the context's fixed inputs. + /// + private List BuildGrid(Pattern rotatedPattern, NestDirection primaryAxis) + { + var perpAxis = + primaryAxis == NestDirection.Horizontal ? NestDirection.Vertical : NestDirection.Horizontal; var workArea = _context.WorkArea; var spacing = _context.Plate.PartSpacing; - var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle); var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis); var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis); - if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis)) - return null; - var stripeEngine = new FillLinear(stripeBox, spacing) { Label = "Stripe", @@ -187,20 +294,7 @@ public class StripeFiller return null; } - var allParts = new List(gridParts); - - // A capped fill that already holds the quantity needs no remnant parts. - if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity) - return allParts; - - var remnantParts = FillRemnant(gridParts, primaryAxis); - if (remnantParts != null) - { - Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts"); - allParts.AddRange(remnantParts); - } - - return allParts; + return gridParts; } private List GetPairCandidates() diff --git a/OpenNest.Engine/Strategies/FillHelpers.cs b/OpenNest.Engine/Strategies/FillHelpers.cs index 35e5a7df..f508a6c2 100644 --- a/OpenNest.Engine/Strategies/FillHelpers.cs +++ b/OpenNest.Engine/Strategies/FillHelpers.cs @@ -169,6 +169,55 @@ namespace OpenNest.Engine.Strategies return best ?? new List(); } + /// + /// for a pure per-angle fill: every angle is filled in + /// parallel, then , the ranking and the progress reports run + /// once per angle in angle order, so the result does not depend on the thread count. + /// must not touch shared state; side effects of an angle's + /// result belong in . + /// + public static List BestOverAnglesInParallel( + FillContext context, + IReadOnlyList angles, + Func> fillAtAngle, + Action> recordResult, + string phaseLabel + ) + { + var results = new List[angles.Count]; + + try + { + Parallel.For( + 0, + angles.Count, + new ParallelOptions { CancellationToken = context.Token }, + i => results[i] = fillAtAngle(angles[i]) + ); + } + catch (AggregateException ex) + { + // Surface the failure itself, cancellation included, as the sequential sweep did. + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(ex.InnerExceptions[0]) + .Throw(); + throw; + } + + var next = 0; + return BestOverAngles( + context, + angles, + angle => + { + var result = results[next++]; + recordResult(angle, result); + return result; + }, + phaseLabel + ); + } + /// /// Checks if any pair of parts geometrically overlap. Uses bounding box /// pre-filtering for performance, then falls back to shape intersection. diff --git a/OpenNest.Engine/Strategies/LinearFillStrategy.cs b/OpenNest.Engine/Strategies/LinearFillStrategy.cs index d3b25d18..f19814ff 100644 --- a/OpenNest.Engine/Strategies/LinearFillStrategy.cs +++ b/OpenNest.Engine/Strategies/LinearFillStrategy.cs @@ -20,7 +20,8 @@ namespace OpenNest.Engine.Strategies var comparer = context.Policy?.Comparer ?? new DefaultFillComparer(); var preferred = context.Policy?.PreferredDirection; - return FillHelpers.BestOverAngles( + // Each angle's fill is independent; its angle result is recorded in angle order. + return FillHelpers.BestOverAnglesInParallel( context, angles, angle => @@ -29,13 +30,15 @@ namespace OpenNest.Engine.Strategies { Label = "Linear", }; - var result = FillHelpers.FillWithDirectionPreference( + return FillHelpers.FillWithDirectionPreference( dir => engine.Fill(context.Item.Drawing, angle, dir), preferred, comparer, workArea ); - + }, + (angle, result) => + { if (result != null && result.Count > 0) { context.AngleResults.Add( @@ -47,8 +50,6 @@ namespace OpenNest.Engine.Strategies } ); } - - return result; }, "Linear" ); diff --git a/OpenNest.Tests/Geometry/ConvexDecompositionEquivalenceTests.cs b/OpenNest.Tests/Geometry/ConvexDecompositionEquivalenceTests.cs new file mode 100644 index 00000000..ea58c19e --- /dev/null +++ b/OpenNest.Tests/Geometry/ConvexDecompositionEquivalenceTests.cs @@ -0,0 +1,210 @@ +using OpenNest.Geometry; +using static OpenNest.Tests.Geometry.NoFitPolygonTests; + +namespace OpenNest.Tests.Geometry; + +/// +/// skips repeating ear tests whose verdict cannot +/// have changed. These tests compare it bit for bit against the frozen pre-change +/// (commit a9fc9f0a): same triangles, same order. +/// +public class ConvexDecompositionEquivalenceTests +{ + [Fact] + public void SeededPolygons_MatchLegacyBitwise() + { + var random = new Random(7112026); + var triangles = 0; + + for (var i = 0; i < 3000; i++) + { + var polygon = MakePolygon(random, i); + var expected = LegacyConvexDecomposition.Triangulate(polygon); + var actual = ConvexDecomposition.Triangulate(polygon); + + Assert.True( + TriangleBits(expected).SequenceEqual(TriangleBits(actual)), + $"polygon {i} (kind {i % 8}, {polygon.Vertices.Count} vertices)" + ); + triangles += expected.Count; + } + + Assert.True(triangles > 50000); + } + + /// + /// Small stars on a coarse grid. Here a vertex's triangle often holds a vertex that is cut + /// as an ear later while the vertex keeps its neighbors, the case the remembered verdict + /// must notice. + /// + [Fact] + public void SmallGridStars_MatchLegacyBitwise() + { + var random = new Random(1); + + for (var i = 0; i < 20000; i++) + { + var count = 5 + random.Next(8); + var angles = Enumerable.Range(0, count) + .Select(_ => random.NextDouble() * 2 * System.Math.PI) + .OrderBy(a => a) + .ToArray(); + var points = angles + .Select(a => + { + var r = 0.2 + random.NextDouble() * 3; + return (System.Math.Round(r * System.Math.Cos(a), 1), System.Math.Round(r * System.Math.Sin(a), 1)); + }) + .ToArray(); + var polygon = Ring(points); + + Assert.True( + TriangleBits(LegacyConvexDecomposition.Triangulate(polygon)) + .SequenceEqual(TriangleBits(ConvexDecomposition.Triangulate(polygon))), + $"star {i}" + ); + } + } + + [Fact] + public void DegeneratePolygons_MatchLegacyBitwise() + { + var cases = new List + { + new Polygon(), + Ring((0, 0), (1, 0)), + Ring((0, 0), (1, 0), (2, 0)), + Ring((0, 0), (1, 0), (2, 0), (3, 0), (1, 1e-12)), + // Repeated vertices and a spike back along an edge. + Ring((0, 0), (2, 0), (2, 0), (2, 2), (1, 1), (2, 2), (0, 2)), + // Self-intersecting bow tie: the scan stops at its safety limit. + Ring((0, 0), (2, 2), (2, 0), (0, 2)), + // The vertex at (0.1, -0.6) is blocked by (-0.3, -0.3) until that vertex is cut. + Ring((1.7, 0.8), (-1.6, -1.3), (-0.3, -0.3), (0.1, -0.6), (1.4, -1.2)), + // Clockwise input is reversed first. + Ring((0, 0), (0, 3), (1, 3), (1, 1), (3, 1), (3, 0)), + }; + + foreach (var polygon in cases) + { + Assert.Equal( + TriangleBits(LegacyConvexDecomposition.Triangulate(polygon)), + TriangleBits(ConvexDecomposition.Triangulate(polygon)) + ); + } + } + + [Fact] + public void Triangulate_DoesNotMutateInput() + { + var polygon = MakePolygon(new Random(3), 1); + var before = polygon.Vertices.ToList(); + + ConvexDecomposition.Triangulate(polygon); + + Assert.Equal(before, polygon.Vertices); + } + + /// + /// Simple polygons of several families: radial stars with many reflex vertices, combs, + /// flattened circles (long runs of nearly collinear vertices), collinear edge points, + /// clockwise copies and far-from-origin offsets. + /// + private static Polygon MakePolygon(Random random, int index) + { + var polygon = (index % 8) switch + { + 0 => Star(random), + 1 => Radial(random, 6 + random.Next(120), 0.2), + 2 => Radial(random, 6 + random.Next(60), 0.9), + 3 => Comb(random), + 4 => Circle(random), + 5 => WithEdgePoints(random, Radial(random, 5 + random.Next(20), 0.6)), + 6 => Reverse(Radial(random, 8 + random.Next(80), 0.5)), + _ => Square(1 + random.NextDouble()), + }; + + return index % 3 == 0 + ? Move(polygon, random.NextDouble() * 400 - 200, random.NextDouble() * 400 - 200) + : polygon; + } + + private static Polygon Radial(Random random, int count, double jitter) + { + var points = new (double X, double Y)[count]; + for (var i = 0; i < count; i++) + { + var angle = 2 * System.Math.PI * i / count; + var radius = 1 + random.NextDouble() * jitter * 3; + points[i] = (radius * System.Math.Cos(angle), radius * System.Math.Sin(angle)); + } + + return Ring(points); + } + + private static Polygon Comb(Random random) + { + var teeth = 2 + random.Next(12); + var width = 0.2 + random.NextDouble(); + var points = new List<(double X, double Y)> { (0, 0), (teeth * width * 2, 0) }; + for (var t = teeth - 1; t >= 0; t--) + { + var x = t * width * 2; + points.Add((x + width * 2, 1 + random.NextDouble())); + points.Add((x + width, 1 + random.NextDouble())); + points.Add((x + width, 0.3)); + points.Add((x, 0.3)); + } + + return Ring(points.ToArray()); + } + + private static Polygon Circle(Random random) + { + var shape = new Shape(); + var radius = 0.5 + random.NextDouble() * 3; + shape.Entities.Add(new Arc(0, 0, radius, 0, System.Math.PI)); + shape.Entities.Add(new Arc(0, 0, radius, System.Math.PI, 2 * System.Math.PI)); + return ClipperBridge.Flatten(shape, 0.001 + random.NextDouble() * 0.05, circumscribe: false); + } + + private static Polygon WithEdgePoints(Random random, Polygon polygon) + { + var points = new List<(double X, double Y)>(); + var vertices = polygon.Vertices; + var count = vertices.Count - 1; + for (var i = 0; i < count; i++) + { + var a = vertices[i]; + var b = vertices[i + 1]; + points.Add((a.X, a.Y)); + var extra = random.Next(3); + for (var k = 1; k <= extra; k++) + { + var t = k / (extra + 1.0); + points.Add((a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t)); + } + } + + return Ring(points.ToArray()); + } + + private static Polygon Reverse(Polygon polygon) + { + var points = polygon.Vertices.Take(polygon.Vertices.Count - 1).Reverse().Select(v => (v.X, v.Y)); + return Ring(points.ToArray()); + } + + private static long[] TriangleBits(List triangles) => + triangles + .SelectMany(t => + t.Vertices.SelectMany(v => new[] + { + BitConverter.DoubleToInt64Bits(v.X), + BitConverter.DoubleToInt64Bits(v.Y), + }) + .Append(t.Vertices.Count) + ) + .Append(triangles.Count) + .ToArray(); +} diff --git a/OpenNest.Tests/Geometry/LegacyConvexDecomposition.cs b/OpenNest.Tests/Geometry/LegacyConvexDecomposition.cs new file mode 100644 index 00000000..46aba63f --- /dev/null +++ b/OpenNest.Tests/Geometry/LegacyConvexDecomposition.cs @@ -0,0 +1,167 @@ +#nullable disable +// Frozen, test-only copy of OpenNest.Core/Geometry/ConvexDecomposition.cs at commit +// a9fc9f0a (before the linked-list ear scan that remembers why a vertex failed). +// Only #nullable disable, the namespace, type name and visibility differ from the original. +// Do not edit: differential tests use it as the independent pre-change oracle. +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry +{ + /// + /// Decomposes concave polygons into convex sub-polygons using ear-clipping + /// triangulation. Produces O(n-2) triangles per polygon. + /// + internal static class LegacyConvexDecomposition + { + /// + /// Decomposes a polygon into a list of convex triangles using ear-clipping. + /// The input polygon must be simple (non-self-intersecting). + /// Returns a list of triangles, each represented as a Polygon with 3 vertices (closed). + /// + public static List Triangulate(Polygon polygon) + { + var triangles = new List(); + var verts = new List(polygon.Vertices); + + // Remove closing vertex if polygon is closed. + if ( + verts.Count > 1 + && verts[0].X == verts[verts.Count - 1].X + && verts[0].Y == verts[verts.Count - 1].Y + ) + verts.RemoveAt(verts.Count - 1); + + if (verts.Count < 3) + return triangles; + + // Ensure counter-clockwise winding for ear detection. + if (SignedArea(verts) < 0) + verts.Reverse(); + + // Build a linked list of vertex indices. + var indices = new List(verts.Count); + + for (var i = 0; i < verts.Count; i++) + indices.Add(i); + + var n = indices.Count; + + // Safety counter to avoid infinite loop on degenerate polygons. + var maxIterations = n * n; + var iterations = 0; + var i0 = 0; + + while (n > 2 && iterations < maxIterations) + { + iterations++; + + var prevIdx = (i0 + n - 1) % n; + var currIdx = i0 % n; + var nextIdx = (i0 + 1) % n; + + var prev = verts[indices[prevIdx]]; + var curr = verts[indices[currIdx]]; + var next = verts[indices[nextIdx]]; + + if (IsEar(prev, curr, next, verts, indices, n)) + { + var tri = new Polygon(); + tri.Vertices.Add(prev); + tri.Vertices.Add(curr); + tri.Vertices.Add(next); + tri.Close(); + triangles.Add(tri); + + indices.RemoveAt(currIdx); + n--; + i0 = 0; + } + else + { + i0++; + + if (i0 >= n) + i0 = 0; + } + } + + return triangles; + } + + /// + /// Tests whether the vertex at curr forms an ear (a convex vertex whose + /// triangle contains no other polygon vertices). + /// + private static bool IsEar( + Vector prev, + Vector curr, + Vector next, + List verts, + List indices, + int n + ) + { + // Must be convex (CCW turn). + if (Cross(prev, curr, next) <= 0) + return false; + + // Check that no other vertex lies inside the triangle. + for (var i = 0; i < n; i++) + { + var v = verts[indices[i]]; + + if (v.X == prev.X && v.Y == prev.Y) + continue; + if (v.X == curr.X && v.Y == curr.Y) + continue; + if (v.X == next.X && v.Y == next.Y) + continue; + + if (PointInTriangle(v, prev, curr, next)) + return false; + } + + return true; + } + + /// + /// Returns positive value if A→B→C is a CCW (left) turn. + /// + internal static double Cross(Vector a, Vector b, Vector c) + { + return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X); + } + + /// + /// Returns true if point p is strictly inside triangle (a, b, c). + /// Assumes CCW winding. + /// + private static bool PointInTriangle(Vector p, Vector a, Vector b, Vector c) + { + var d1 = Cross(a, b, p); + var d2 = Cross(b, c, p); + var d3 = Cross(c, a, p); + + var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0); + var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0); + + return !(hasNeg && hasPos); + } + + /// + /// Signed area of a polygon. Positive = CCW, negative = CW. + /// + private static double SignedArea(List verts) + { + // World-coordinate products can erase the sign of a small polygon's area + // far from the origin, leaving CW outlines/holes untriangulated. Measure + // relative to a vertex, just as the clipping kernel measures its fragments. + var area = 0.0; + for (var i = 1; i + 1 < verts.Count; i++) + area += Cross(verts[0], verts[i], verts[i + 1]); + + return area * 0.5; + } + } +}