fix: orient pair short side along primary axis before convergence

The convergence loop now ensures the pair starts with its short side
parallel to the primary axis, maximizing the number of pairs that fit.
Also adds ConvergeStripeAngleShrink to try N+1 narrower pairs, and
evaluates both expand and shrink results to pick the better grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:22:13 -04:00
parent 811d23510e
commit 3f3d95a5e4
2 changed files with 166 additions and 37 deletions

View File

@@ -49,41 +49,31 @@ public class StripeFiller
var candidate = bestFits[i];
var pairParts = candidate.BuildParts(drawing);
var (angle, waste, count) = ConvergeStripeAngle(
// Try both expand (N wider pairs) and shrink (N+1 narrower pairs)
var expandResult = ConvergeStripeAngle(
pairParts, sheetSpan, spacing, _primaryAxis, _context.Token);
var shrinkResult = ConvergeStripeAngleShrink(
pairParts, sheetSpan, spacing, _primaryAxis, _context.Token);
if (count <= 0)
continue;
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, _primaryAxis);
var stripeEngine = new FillLinear(stripeBox, spacing);
var stripeParts = stripeEngine.Fill(rotatedPattern, _primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
continue;
var stripePattern = new Pattern();
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing);
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
continue;
var allParts = new List<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, drawing, angle, workArea, spacing);
if (remnantParts != null)
allParts.AddRange(remnantParts);
var score = FillScore.Compute(allParts, workArea);
if (bestParts == null || score > bestScore)
// Evaluate both convergence results
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
{
bestParts = allParts;
bestScore = score;
if (count <= 0)
continue;
var result = BuildGrid(pairParts, angle, workArea, spacing, perpAxis, drawing);
if (result == null || result.Count == 0)
continue;
var score = FillScore.Compute(result, workArea);
Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i}: angle={Angle.ToDegrees(angle):F1}°, " +
$"pairsAcross={count}, waste={waste:F2}, grid={result.Count} parts");
if (bestParts == null || score > bestScore)
{
bestParts = result;
bestScore = score;
}
}
NestEngineBase.ReportProgress(_context.Progress, NestPhase.Custom,
@@ -94,6 +84,44 @@ public class StripeFiller
return bestParts ?? new List<Part>();
}
private List<Part> BuildGrid(List<Part> pairParts, double angle,
Box workArea, double spacing, NestDirection perpAxis, Drawing drawing)
{
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, _primaryAxis);
var stripeEngine = new FillLinear(stripeBox, spacing);
var stripeParts = stripeEngine.Fill(rotatedPattern, _primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
return null;
Debug.WriteLine($"[StripeFiller] Stripe: {stripeParts.Count} parts, " +
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
var stripePattern = new Pattern();
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing);
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
return null;
Debug.WriteLine($"[StripeFiller] Grid: {gridParts.Count} parts");
var allParts = new List<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, drawing, angle, workArea, spacing);
if (remnantParts != null)
{
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
allParts.AddRange(remnantParts);
}
return allParts;
}
private List<BestFitResult> GetPairCandidates()
{
List<BestFitResult> bestFits;
@@ -200,18 +228,36 @@ public class StripeFiller
return bestAngle;
}
/// <summary>
/// Returns the rotation angle that orients the pair with its short side
/// along the given axis. Returns 0 if already oriented, PI/2 if rotated.
/// </summary>
private static double OrientShortSideAlong(List<Part> patternParts, NestDirection axis)
{
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
var span0 = GetDimension(box, axis);
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
// Short side already along axis
if (span0 <= perpSpan0)
return 0;
// Rotate 90° to put short side along axis
return Angle.HalfPI;
}
/// <summary>
/// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste.
/// span the given dimension with minimal waste by expanding pair width.
/// Returns (angle, waste, pairCount).
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
var currentAngle = 0.0;
var currentAngle = OrientShortSideAlong(patternParts, axis);
var bestWaste = double.MaxValue;
var bestAngle = 0.0;
var bestAngle = currentAngle;
var bestCount = 0;
var tolerance = sheetSpan * 0.001;
@@ -251,6 +297,88 @@ public class StripeFiller
return (bestAngle, bestWaste, bestCount);
}
/// <summary>
/// Tries fitting N+1 narrower pairs by shrinking the pair width.
/// Complements ConvergeStripeAngle which only expands.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngleShrink(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
// Orient short side along axis before computing N
var baseAngle = OrientShortSideAlong(patternParts, axis);
var naturalPattern = FillHelpers.BuildRotatedPattern(patternParts, baseAngle);
var naturalSpan = GetDimension(naturalPattern.BoundingBox, axis);
if (naturalSpan + spacing <= 0)
return (0, double.MaxValue, 0);
var naturalN = (int)System.Math.Floor((sheetSpan + spacing) / (naturalSpan + spacing));
var targetN = naturalN + 1;
// Target span for N+1 pairs
var targetSpan = (sheetSpan + spacing) / targetN - spacing;
if (targetSpan <= 0)
return (0, double.MaxValue, 0);
// Find the angle that shrinks the pair to targetSpan
var angle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
// Verify the actual span at this angle
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
var actualSpan = GetDimension(rotated.BoundingBox, axis);
var actualN = (int)System.Math.Floor((sheetSpan + spacing) / (actualSpan + spacing));
if (actualN <= 0)
return (0, double.MaxValue, 0);
var usedSpan = actualN * (actualSpan + spacing) - spacing;
var waste = sheetSpan - usedSpan;
// Now converge from this starting point
var bestWaste = waste;
var bestAngle = angle;
var bestCount = actualN;
var tolerance = sheetSpan * 0.001;
var currentAngle = angle;
for (var iteration = 0; iteration < MaxConvergenceIterations; iteration++)
{
token.ThrowIfCancellationRequested();
if (bestWaste <= tolerance)
break;
rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
var pairSpan = GetDimension(rotated.BoundingBox, axis);
var n = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
if (n <= 0)
break;
usedSpan = n * (pairSpan + spacing) - spacing;
var remaining = sheetSpan - usedSpan;
if (remaining < bestWaste)
{
bestWaste = remaining;
bestAngle = currentAngle;
bestCount = n;
}
if (remaining <= tolerance)
break;
var delta = remaining / n;
var newTarget = pairSpan + delta;
currentAngle = FindAngleForTargetSpan(patternParts, newTarget, axis);
}
return (bestAngle, bestWaste, bestCount);
}
private static double BisectForTarget(
List<Part> patternParts, double lo, double hi,
double targetSpan, NestDirection axis)

View File

@@ -114,12 +114,13 @@ public class StripeFillerTests
[Fact]
public void ConvergeStripeAngle_HandlesExactFit()
{
// 10x5 pattern: short side (5) oriented along axis, so more pairs fit
var pattern = MakeRectPattern(10, 5);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 100.0, 0.0, NestDirection.Horizontal);
Assert.Equal(10, count);
Assert.True(waste < 0.2, $"Expected near-zero waste, got {waste:F2}");
Assert.True(count >= 10, $"Expected at least 10 pairs, got {count}");
Assert.True(waste < 1.0, $"Expected low waste, got {waste:F2}");
}
[Fact]