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)
|
foreach (var shape in shapes)
|
||||||
{
|
{
|
||||||
var polygon = shape.ToPolygonWithTolerance(0.1);
|
var polygon = shape.ToPolygonWithTolerance(0.01);
|
||||||
points.AddRange(polygon.Vertices);
|
points.AddRange(polygon.Vertices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace OpenNest
|
|||||||
public Box WorkArea { get; }
|
public Box WorkArea { get; }
|
||||||
|
|
||||||
public double PartSpacing { get; }
|
public double PartSpacing { get; }
|
||||||
|
|
||||||
public double HalfSpacing => PartSpacing / 2;
|
public double HalfSpacing => PartSpacing / 2;
|
||||||
|
|
||||||
private static Vector MakeOffset(NestDirection direction, double distance)
|
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||||
@@ -227,7 +227,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tiles a pattern along the given axis, returning the cloned parts
|
/// 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>
|
/// </summary>
|
||||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
|
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
|
||||||
{
|
{
|
||||||
@@ -255,6 +257,26 @@ namespace OpenNest
|
|||||||
count++;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
@@ -35,23 +35,48 @@ namespace OpenNest
|
|||||||
|
|
||||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
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),
|
// Try every 5° from 0 to 175° to find rotations that fit.
|
||||||
engine.Fill(item.Drawing, bestRotation, NestDirection.Vertical),
|
var step = Angle.ToRadians(5);
|
||||||
engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Horizontal),
|
|
||||||
engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Vertical)
|
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;
|
List<Part> best = null;
|
||||||
|
|
||||||
foreach (var config in configs)
|
foreach (var angle in angles)
|
||||||
{
|
{
|
||||||
if (IsBetterFill(config, best))
|
var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal);
|
||||||
best = config;
|
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).
|
// Try rectangle best-fit (mixes orientations to fill remnant strips).
|
||||||
var rectResult = FillRectangleBestFit(item, workArea);
|
var rectResult = FillRectangleBestFit(item, workArea);
|
||||||
@@ -152,14 +177,14 @@ namespace OpenNest
|
|||||||
item.Drawing, Plate.Size.Width, Plate.Size.Height,
|
item.Drawing, Plate.Size.Width, Plate.Size.Height,
|
||||||
Plate.PartSpacing);
|
Plate.PartSpacing);
|
||||||
|
|
||||||
var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList();
|
var candidates = SelectPairCandidates(bestFits, workArea);
|
||||||
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}");
|
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)>();
|
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 pairParts = result.BuildParts(item.Drawing);
|
||||||
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
||||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||||
@@ -181,6 +206,41 @@ namespace OpenNest
|
|||||||
return best ?? new List<Part>();
|
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)
|
private bool HasOverlaps(List<Part> parts, double spacing)
|
||||||
{
|
{
|
||||||
if (parts == null || parts.Count <= 1)
|
if (parts == null || parts.Count <= 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user