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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user