feat: improve remnant fill with rotation sweep, smart pair selection, and partial pattern fill

Narrow remnant strips now get more parts by:
- Sweeping rotations every 5° when the strip is narrower than the part
- Including all pairs that fit the strip width (not just top 50 by area)
- Placing individual parts from incomplete pattern copies that still fit
- Using finer polygon tolerance (0.01) for hull edge angle detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 18:33:06 -04:00
parent c5a9c27160
commit 435a08074b
3 changed files with 100 additions and 18 deletions

View File

@@ -115,7 +115,7 @@ namespace OpenNest.Engine.BestFit
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(0.1);
var polygon = shape.ToPolygonWithTolerance(0.01);
points.AddRange(polygon.Vertices);
}

View File

@@ -15,7 +15,7 @@ namespace OpenNest
public Box WorkArea { get; }
public double PartSpacing { get; }
public double HalfSpacing => PartSpacing / 2;
private static Vector MakeOffset(NestDirection direction, double distance)
@@ -227,7 +227,9 @@ namespace OpenNest
/// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts).
/// (does not include the original pattern's parts). For multi-part
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
{
@@ -255,6 +257,26 @@ namespace OpenNest
count++;
}
// For multi-part patterns, try to place individual parts from the
// next copy that didn't fit as a whole. This handles cases where
// e.g. a 2-part pair only partially fits — one part may still be
// within the work area even though the full pattern exceeds it.
if (basePattern.Parts.Count > 1)
{
var partialClone = basePattern.Clone(MakeOffset(direction, copyDistance * count));
foreach (var part in partialClone.Parts)
{
if (part.BoundingBox.Right <= WorkArea.Right + Tolerance.Epsilon &&
part.BoundingBox.Top <= WorkArea.Top + Tolerance.Epsilon &&
part.BoundingBox.Left >= WorkArea.Left - Tolerance.Epsilon &&
part.BoundingBox.Bottom >= WorkArea.Bottom - Tolerance.Epsilon)
{
result.Add(part);
}
}
}
return result;
}

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using OpenNest.Engine.BestFit;
@@ -35,23 +35,48 @@ namespace OpenNest
var engine = new FillLinear(workArea, Plate.PartSpacing);
var configs = new[]
// Build candidate rotation angles — always try the best rotation and +90°.
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
// When the work area is narrow relative to the part, sweep rotation
// angles so we can find one that fits the part into the tight strip.
var testPart = new Part(item.Drawing);
if (!bestRotation.IsEqualTo(0))
testPart.Rotate(bestRotation);
testPart.UpdateBounds();
var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Height);
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height);
if (workAreaShortSide < partLongestSide)
{
engine.Fill(item.Drawing, bestRotation, NestDirection.Horizontal),
engine.Fill(item.Drawing, bestRotation, NestDirection.Vertical),
engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Horizontal),
engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Vertical)
};
// Try every 5° from 0 to 175° to find rotations that fit.
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!angles.Any(existing => existing.IsEqualTo(a)))
angles.Add(a);
}
}
List<Part> best = null;
foreach (var config in configs)
foreach (var angle in angles)
{
if (IsBetterFill(config, best))
best = config;
var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal);
var v = engine.Fill(item.Drawing, angle, NestDirection.Vertical);
if (IsBetterFill(h, best))
best = h;
if (IsBetterFill(v, best))
best = v;
}
Debug.WriteLine($"[Fill(NestItem,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}");
Debug.WriteLine($"[Fill(NestItem,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
// Try rectangle best-fit (mixes orientations to fill remnant strips).
var rectResult = FillRectangleBestFit(item, workArea);
@@ -152,14 +177,14 @@ namespace OpenNest
item.Drawing, Plate.Size.Width, Plate.Size.Height,
Plate.PartSpacing);
var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList();
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}");
var candidates = SelectPairCandidates(bestFits, workArea);
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(int count, List<Part> parts)>();
System.Threading.Tasks.Parallel.For(0, keptResults.Count, i =>
System.Threading.Tasks.Parallel.For(0, candidates.Count, i =>
{
var result = keptResults[i];
var result = candidates[i];
var pairParts = result.BuildParts(item.Drawing);
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
var engine = new FillLinear(workArea, Plate.PartSpacing);
@@ -181,6 +206,41 @@ namespace OpenNest
return best ?? new List<Part>();
}
/// <summary>
/// Selects pair candidates to try for the given work area. Always includes
/// the top 50 by area. For narrow work areas, also includes all pairs whose
/// shortest side fits the strip width — these are candidates that can only
/// be evaluated by actually tiling them into the narrow space.
/// </summary>
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
{
var kept = bestFits.Where(r => r.Keep).ToList();
var top = kept.Take(50).ToList();
var workShortSide = System.Math.Min(workArea.Width, workArea.Height);
var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Height);
// When the work area is significantly narrower than the plate,
// include all pairs that fit the narrow dimension.
if (workShortSide < plateShortSide * 0.5)
{
var stripCandidates = kept
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon);
var existing = new HashSet<BestFitResult>(top);
foreach (var r in stripCandidates)
{
if (existing.Add(r))
top.Add(r);
}
Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
}
return top;
}
private bool HasOverlaps(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)