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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-07 19:34:31 -05:00
1 parent 331234d6f3
commit bcbcf1e611
10 files changed
+816 -109

No files matched your search

+115 -20
View File
@@ -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;
}
/// <summary>
@@ -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;
}
/// <summary>
@@ -138,30 +138,26 @@ namespace OpenNest.Geometry
Polygon a,
Polygon b,
List<Polygon> holesA,
List<Polygon> holesB
) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB);
List<Polygon> holesB,
bool verdictOnly = false
) => OverlapRegions(TriangulateWithBounds(a), TriangulateWithBounds(b), holesA, holesB, verdictOnly);
private static List<Polygon> OverlapRegions(
List<Polygon> trisA,
List<Polygon> trisB,
List<Polygon> holesA,
List<Polygon> holesB
List<Polygon> holesB,
bool verdictOnly = false
)
{
var regions = new List<Polygon>();
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;
}
/// <summary>
/// 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.
/// </summary>
private static void ClipTrianglePairs(
List<Polygon> trisA,
List<Polygon> trisB,
List<Polygon> 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<Vector>[candidatesB.Count];
for (var j = 0; j < candidatesB.Count; j++)
openB[j] = OpenVertices(candidatesB[j]);
foreach (var triA in candidatesA)
{
List<Vector> 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<Polygon> 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);
}
/// <summary>
/// The polygons whose boxes pass <see cref="BoundingBoxesOverlap"/> against
/// <paramref name="union"/>, in order. Same arithmetic as that test, with the union's
/// sides in place of the other box's.
/// </summary>
private static List<Polygon> TrianglesTouching(
List<Polygon> polygons,
(double Left, double Right, double Bottom, double Top) union
)
{
var result = new List<Polygon>(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.
/// </summary>
private static Polygon ClipConvex(Polygon subject, Polygon clip)
private static Polygon ClipConvex(List<Vector> subjectVerts, List<Vector> 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);
+66 -23
View File
@@ -33,32 +33,57 @@ namespace OpenNest.Geometry
if (SignedArea(verts) < 0)
verts.Reverse();
// Build a linked list of vertex indices.
var indices = new List<int>(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
/// <summary>
/// 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.
/// </summary>
private static bool IsEar(
Vector prev,
Vector curr,
Vector next,
List<Vector> verts,
List<int> 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;
+29 -3
View File
@@ -743,16 +743,40 @@ namespace OpenNest.Geometry
Vector direction,
SlideContactClassifier contacts
)
{
return DirectionalDistance(
movingEntities,
ExtractEntityVertices(movingEntities),
stationaryEntities,
ExtractEntityVertices(stationaryEntities),
direction,
contacts
);
}
/// <summary>
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector, SlideContactClassifier)"/>
/// with each list's <see cref="ExtractEntityVertices"/> result supplied by a caller that
/// slides one list against several others. The vertices must come from these exact lists.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities,
Vector[] movingVertices,
List<Entity> 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<Entity> entities)
{
var vertices = new HashSet<Vector>();
// 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<Vector>(entities.Count * 2);
for (var i = 0; i < entities.Count; i++)
{
+18 -3
View File
@@ -148,6 +148,9 @@ namespace OpenNest.Engine.Fill
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[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)
+7
View File
@@ -24,6 +24,13 @@ public class GridDedup
return _seen.TryAdd(key, 0);
}
/// <summary>
/// True if <see cref="TryAdd"/> already accepted this configuration. Claims are never
/// removed, so a configuration seen now stays rejected.
/// </summary>
public bool Contains(Box patternBox, Box workArea, NestDirection dir) =>
_seen.ContainsKey(new GridKey(patternBox, workArea, dir));
public int Count => _seen.Count;
/// <summary>
+149 -55
View File
@@ -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<Part>();
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<Part> 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<Part>();
}
private List<Part> BuildGrid(
List<Part> pairParts,
double angle,
NestDirection primaryAxis,
NestDirection perpAxis
)
private static readonly NestDirection[] Axes = { NestDirection.Horizontal, NestDirection.Vertical };
/// <summary>Expanding (<see cref="ConvergeStripeAngle"/>) then shrinking convergence.</summary>
private const int VariantsPerAxis = 2;
private static int LayoutIndex(int candidate, int axis, int variant) =>
(candidate * Axes.Length + axis) * VariantsPerAxis + variant;
/// <summary>
/// A converged stripe angle and, when it holds a pattern, the rotated pattern and the grid
/// it tiles before the order-dependent steps (<see cref="GridDedup"/> claim, cached remnant
/// fill).
/// </summary>
private sealed class StripeLayout
{
public double Angle;
public double Waste;
public int Count;
public Pattern Pattern;
public bool GridReady;
public List<Part> Grid;
}
/// <summary>
/// Converges every candidate's stripe angle per axis and variant, then tiles the grids that
/// <see cref="Fill"/> will claim, each step in parallel. Every layout is a pure function of
/// its candidate, axis and variant (fresh part clones, per-call <see cref="FillLinear"/>
/// 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.
/// <see cref="Fill"/> then claims, fills remnants and ranks in the original order.
/// </summary>
private StripeLayout[] ComputeLayouts(List<BestFitResult> 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<int>();
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<int> 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<Part> 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;
}
/// <summary>
/// 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.
/// </summary>
private List<Part> 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<Part>(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;
}
/// <summary>
/// Tiles the rotated pattern into a stripe and the stripe into a grid. Pure: reads only the
/// pattern and the context's fixed inputs.
/// </summary>
private List<Part> 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<Part>(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<BestFitResult> GetPairCandidates()
+49
View File
@@ -169,6 +169,55 @@ namespace OpenNest.Engine.Strategies
return best ?? new List<Part>();
}
/// <summary>
/// <see cref="BestOverAngles"/> for a pure per-angle fill: every angle is filled in
/// parallel, then <paramref name="recordResult"/>, the ranking and the progress reports run
/// once per angle in angle order, so the result does not depend on the thread count.
/// <paramref name="fillAtAngle"/> must not touch shared state; side effects of an angle's
/// result belong in <paramref name="recordResult"/>.
/// </summary>
public static List<Part> BestOverAnglesInParallel(
FillContext context,
IReadOnlyList<double> angles,
Func<double, List<Part>> fillAtAngle,
Action<double, List<Part>> recordResult,
string phaseLabel
)
{
var results = new List<Part>[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
);
}
/// <summary>
/// Checks if any pair of parts geometrically overlap. Uses bounding box
/// pre-filtering for performance, then falls back to shape intersection.
@@ -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"
);
@@ -0,0 +1,210 @@
using OpenNest.Geometry;
using static OpenNest.Tests.Geometry.NoFitPolygonTests;
namespace OpenNest.Tests.Geometry;
/// <summary>
/// <see cref="ConvexDecomposition.Triangulate"/> skips repeating ear tests whose verdict cannot
/// have changed. These tests compare it bit for bit against the frozen pre-change
/// <see cref="LegacyConvexDecomposition"/> (commit a9fc9f0a): same triangles, same order.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
[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<Polygon>
{
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);
}
/// <summary>
/// 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.
/// </summary>
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<Polygon> 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();
}
@@ -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
{
/// <summary>
/// Decomposes concave polygons into convex sub-polygons using ear-clipping
/// triangulation. Produces O(n-2) triangles per polygon.
/// </summary>
internal static class LegacyConvexDecomposition
{
/// <summary>
/// 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).
/// </summary>
public static List<Polygon> Triangulate(Polygon polygon)
{
var triangles = new List<Polygon>();
var verts = new List<Vector>(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<int>(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;
}
/// <summary>
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
/// triangle contains no other polygon vertices).
/// </summary>
private static bool IsEar(
Vector prev,
Vector curr,
Vector next,
List<Vector> verts,
List<int> 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;
}
/// <summary>
/// Returns positive value if A→B→C is a CCW (left) turn.
/// </summary>
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);
}
/// <summary>
/// Returns true if point p is strictly inside triangle (a, b, c).
/// Assumes CCW winding.
/// </summary>
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);
}
/// <summary>
/// Signed area of a polygon. Positive = CCW, negative = CW.
/// </summary>
private static double SignedArea(List<Vector> 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;
}
}
}