feat: improve BLF with Clipper paths, spatial pruning, and progress
Refactor BLF to compute NFP paths as Clipper PathsD with offsets instead of translating full polygons. Add spatial pruning to skip NFPs that don't intersect the IFP bounds. Clamp placement points to IFP bounds to correct Clipper2 floating-point drift. Add progress reporting to simulated annealing. Add debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Engine.Nfp
|
||||
{
|
||||
@@ -10,6 +13,9 @@ namespace OpenNest.Engine.Nfp
|
||||
/// </summary>
|
||||
public class BottomLeftFill
|
||||
{
|
||||
private static readonly string DebugLogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log");
|
||||
|
||||
private readonly Box workArea;
|
||||
private readonly NfpCache nfpCache;
|
||||
|
||||
@@ -21,55 +27,56 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
/// <summary>
|
||||
/// Places parts according to the given sequence using NFP-based BLF.
|
||||
/// Each entry is (drawingId, rotation) determining what to place and how.
|
||||
/// Returns the list of successfully placed parts with their positions.
|
||||
/// </summary>
|
||||
public List<PlacedPart> Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence)
|
||||
public List<PlacedPart> Fill(List<SequenceEntry> sequence)
|
||||
{
|
||||
var placedParts = new List<PlacedPart>();
|
||||
|
||||
foreach (var (drawingId, rotation, drawing) in sequence)
|
||||
using var log = new StreamWriter(DebugLogPath, false);
|
||||
log.WriteLine($"[BLF] {DateTime.Now:HH:mm:ss.fff} workArea: X={workArea.X} Y={workArea.Y} W={workArea.Width} H={workArea.Length} Right={workArea.Right} Top={workArea.Top}");
|
||||
log.WriteLine($"[BLF] Sequence count: {sequence.Count}");
|
||||
|
||||
foreach (var entry in sequence)
|
||||
{
|
||||
var polygon = nfpCache.GetPolygon(drawingId, rotation);
|
||||
|
||||
if (polygon == null || polygon.Vertices.Count < 3)
|
||||
continue;
|
||||
|
||||
// Compute IFP for this part inside the work area.
|
||||
var ifp = InnerFitPolygon.Compute(workArea, polygon);
|
||||
var ifp = nfpCache.GetIfp(entry.DrawingId, entry.Rotation, workArea);
|
||||
|
||||
if (ifp.Vertices.Count < 3)
|
||||
continue;
|
||||
|
||||
// Compute NFPs against all already-placed parts.
|
||||
var nfps = new Polygon[placedParts.Count];
|
||||
|
||||
for (var i = 0; i < placedParts.Count; i++)
|
||||
{
|
||||
var placed = placedParts[i];
|
||||
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
|
||||
|
||||
// Translate NFP to the placed part's position.
|
||||
var translated = TranslatePolygon(nfp, placed.Position);
|
||||
nfps[i] = translated;
|
||||
log.WriteLine($"[BLF] DrawingId={entry.DrawingId} rot={entry.Rotation:F3} SKIPPED (IFP has {ifp.Vertices.Count} verts)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute feasible region and find bottom-left point.
|
||||
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfps);
|
||||
log.WriteLine($"[BLF] DrawingId={entry.DrawingId} rot={entry.Rotation:F3} IFP verts={ifp.Vertices.Count} bounds=({ifp.BoundingBox.X:F2},{ifp.BoundingBox.Y:F2},{ifp.BoundingBox.Width:F2},{ifp.BoundingBox.Length:F2})");
|
||||
|
||||
var nfpPaths = ComputeNfpPaths(placedParts, entry.DrawingId, entry.Rotation, ifp.BoundingBox);
|
||||
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfpPaths);
|
||||
var point = InnerFitPolygon.FindBottomLeftPoint(feasible);
|
||||
|
||||
if (double.IsNaN(point.X))
|
||||
{
|
||||
log.WriteLine($"[BLF] -> NO feasible point (NaN)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clamp to IFP bounds to correct Clipper2 floating-point drift.
|
||||
var ifpBb = ifp.BoundingBox;
|
||||
point = new Vector(
|
||||
System.Math.Max(ifpBb.X, System.Math.Min(ifpBb.Right, point.X)),
|
||||
System.Math.Max(ifpBb.Y, System.Math.Min(ifpBb.Top, point.Y)));
|
||||
|
||||
log.WriteLine($"[BLF] -> placed at ({point.X:F4}, {point.Y:F4}) nfpPaths={nfpPaths.Count} feasibleVerts={feasible.Vertices.Count}");
|
||||
|
||||
placedParts.Add(new PlacedPart
|
||||
{
|
||||
DrawingId = drawingId,
|
||||
Rotation = rotation,
|
||||
DrawingId = entry.DrawingId,
|
||||
Rotation = entry.Rotation,
|
||||
Position = point,
|
||||
Drawing = drawing
|
||||
Drawing = entry.Drawing
|
||||
});
|
||||
}
|
||||
|
||||
log.WriteLine($"[BLF] Total placed: {placedParts.Count}/{sequence.Count}");
|
||||
return placedParts;
|
||||
}
|
||||
|
||||
@@ -82,12 +89,12 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
foreach (var placed in placedParts)
|
||||
{
|
||||
var part = new Part(placed.Drawing);
|
||||
|
||||
if (placed.Rotation != 0)
|
||||
part.Rotate(placed.Rotation);
|
||||
|
||||
part.Location = placed.Position;
|
||||
var part = Part.CreateAtOrigin(placed.Drawing, placed.Rotation);
|
||||
// CreateAtOrigin sets Location to compensate for the rotated program's
|
||||
// bounding box offset. The BLF position is a displacement for the
|
||||
// origin-normalized polygon, so we ADD it to the existing Location
|
||||
// rather than replacing it.
|
||||
part.Location = part.Location + placed.Position;
|
||||
parts.Add(part);
|
||||
}
|
||||
|
||||
@@ -95,27 +102,31 @@ namespace OpenNest.Engine.Nfp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a translated copy of a polygon.
|
||||
/// Computes NFPs for a candidate part against all already-placed parts,
|
||||
/// returned as Clipper paths with translations applied.
|
||||
/// Filters NFPs that don't intersect the target IFP.
|
||||
/// </summary>
|
||||
private static Polygon TranslatePolygon(Polygon polygon, Vector offset)
|
||||
private PathsD ComputeNfpPaths(List<PlacedPart> placedParts, int drawingId, double rotation, Box ifpBounds)
|
||||
{
|
||||
var result = new Polygon();
|
||||
var nfpPaths = new PathsD(placedParts.Count);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
result.Vertices.Add(new Vector(v.X + offset.X, v.Y + offset.Y));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a part that has been placed by the BLF algorithm.
|
||||
/// </summary>
|
||||
public class PlacedPart
|
||||
for (var i = 0; i < placedParts.Count; i++)
|
||||
{
|
||||
public int DrawingId { get; set; }
|
||||
public double Rotation { get; set; }
|
||||
public Vector Position { get; set; }
|
||||
public Drawing Drawing { get; set; }
|
||||
var placed = placedParts[i];
|
||||
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
|
||||
|
||||
if (nfp != null && nfp.Vertices.Count >= 3)
|
||||
{
|
||||
// Spatial pruning: only include NFPs that could actually subtract from the IFP.
|
||||
var nfpBounds = nfp.BoundingBox.Translate(placed.Position);
|
||||
if (nfpBounds.Intersects(ifpBounds))
|
||||
{
|
||||
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp, placed.Position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nfpPaths;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
public OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
Dictionary<int, List<double>> candidateRotations,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
var random = new Random();
|
||||
|
||||
// Build initial sequence: expand NestItems into individual (drawingId, rotation, drawing) entries,
|
||||
// Build initial sequence: expand NestItems into individual entries,
|
||||
// sorted by area descending.
|
||||
var sequence = BuildInitialSequence(items, candidateRotations);
|
||||
|
||||
@@ -35,9 +36,9 @@ namespace OpenNest.Engine.Nfp
|
||||
var blf = new BottomLeftFill(workArea, cache);
|
||||
var bestPlaced = blf.Fill(sequence);
|
||||
var bestScore = FillScore.Compute(BottomLeftFill.ToNestParts(bestPlaced), workArea);
|
||||
var bestSequence = new List<(int, double, Drawing)>(sequence);
|
||||
var bestSequence = new List<SequenceEntry>(sequence);
|
||||
|
||||
var currentSequence = new List<(int, double, Drawing)>(sequence);
|
||||
var currentSequence = new List<SequenceEntry>(sequence);
|
||||
var currentScore = bestScore;
|
||||
|
||||
// Calibrate initial temperature so ~80% of worse moves are accepted.
|
||||
@@ -49,13 +50,16 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
Debug.WriteLine($"[SA] Initial: {bestScore.Count} parts, density={bestScore.Density:P1}, temp={initialTemp:F2}");
|
||||
|
||||
ReportBest(progress, BottomLeftFill.ToNestParts(bestPlaced), workArea,
|
||||
$"NFP: initial {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
|
||||
while (temperature > DefaultMinTemperature
|
||||
&& noImprovement < DefaultMaxNoImprovement
|
||||
&& !cancellation.IsCancellationRequested)
|
||||
{
|
||||
iteration++;
|
||||
|
||||
var candidate = new List<(int drawingId, double rotation, Drawing drawing)>(currentSequence);
|
||||
var candidate = new List<SequenceEntry>(currentSequence);
|
||||
Mutate(candidate, candidateRotations, random);
|
||||
|
||||
var candidatePlaced = blf.Fill(candidate);
|
||||
@@ -72,10 +76,13 @@ namespace OpenNest.Engine.Nfp
|
||||
if (currentScore > bestScore)
|
||||
{
|
||||
bestScore = currentScore;
|
||||
bestSequence = new List<(int, double, Drawing)>(currentSequence);
|
||||
bestSequence = new List<SequenceEntry>(currentSequence);
|
||||
noImprovement = 0;
|
||||
|
||||
Debug.WriteLine($"[SA] New best at iter {iteration}: {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
|
||||
ReportBest(progress, BottomLeftFill.ToNestParts(candidatePlaced), workArea,
|
||||
$"NFP: iter {iteration}, {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -118,10 +125,10 @@ namespace OpenNest.Engine.Nfp
|
||||
/// Builds the initial placement sequence sorted by drawing area descending.
|
||||
/// Each NestItem is expanded by its quantity.
|
||||
/// </summary>
|
||||
private static List<(int drawingId, double rotation, Drawing drawing)> BuildInitialSequence(
|
||||
private static List<SequenceEntry> BuildInitialSequence(
|
||||
List<NestItem> items, Dictionary<int, List<double>> candidateRotations)
|
||||
{
|
||||
var sequence = new List<(int drawingId, double rotation, Drawing drawing)>();
|
||||
var sequence = new List<SequenceEntry>();
|
||||
|
||||
// Sort items by area descending.
|
||||
var sorted = items.OrderByDescending(i => i.Drawing.Area).ToList();
|
||||
@@ -135,7 +142,7 @@ namespace OpenNest.Engine.Nfp
|
||||
rotation = rotations[0];
|
||||
|
||||
for (var i = 0; i < qty; i++)
|
||||
sequence.Add((item.Drawing.Id, rotation, item.Drawing));
|
||||
sequence.Add(new SequenceEntry(item.Drawing.Id, rotation, item.Drawing));
|
||||
}
|
||||
|
||||
return sequence;
|
||||
@@ -144,7 +151,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// <summary>
|
||||
/// Applies a random mutation to the sequence.
|
||||
/// </summary>
|
||||
private static void Mutate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
|
||||
private static void Mutate(List<SequenceEntry> sequence,
|
||||
Dictionary<int, List<double>> candidateRotations, Random random)
|
||||
{
|
||||
if (sequence.Count < 2)
|
||||
@@ -169,7 +176,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// <summary>
|
||||
/// Swaps two random parts in the sequence.
|
||||
/// </summary>
|
||||
private static void MutateSwap(List<(int, double, Drawing)> sequence, Random random)
|
||||
private static void MutateSwap(List<SequenceEntry> sequence, Random random)
|
||||
{
|
||||
var i = random.Next(sequence.Count);
|
||||
var j = random.Next(sequence.Count);
|
||||
@@ -183,23 +190,23 @@ namespace OpenNest.Engine.Nfp
|
||||
/// <summary>
|
||||
/// Changes a random part's rotation to another candidate angle.
|
||||
/// </summary>
|
||||
private static void MutateRotate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
|
||||
private static void MutateRotate(List<SequenceEntry> sequence,
|
||||
Dictionary<int, List<double>> candidateRotations, Random random)
|
||||
{
|
||||
var idx = random.Next(sequence.Count);
|
||||
var entry = sequence[idx];
|
||||
|
||||
if (!candidateRotations.TryGetValue(entry.drawingId, out var rotations) || rotations.Count <= 1)
|
||||
if (!candidateRotations.TryGetValue(entry.DrawingId, out var rotations) || rotations.Count <= 1)
|
||||
return;
|
||||
|
||||
var newRotation = rotations[random.Next(rotations.Count)];
|
||||
sequence[idx] = (entry.drawingId, newRotation, entry.drawing);
|
||||
sequence[idx] = entry.WithRotation(newRotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses a random contiguous subsequence.
|
||||
/// </summary>
|
||||
private static void MutateReverse(List<(int, double, Drawing)> sequence, Random random)
|
||||
private static void MutateReverse(List<SequenceEntry> sequence, Random random)
|
||||
{
|
||||
var i = random.Next(sequence.Count);
|
||||
var j = random.Next(sequence.Count);
|
||||
@@ -221,7 +228,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// are accepted initially.
|
||||
/// </summary>
|
||||
private static double CalibrateTemperature(
|
||||
List<(int drawingId, double rotation, Drawing drawing)> sequence,
|
||||
List<SequenceEntry> sequence,
|
||||
Box workArea, NfpCache cache,
|
||||
Dictionary<int, List<double>> candidateRotations, Random random)
|
||||
{
|
||||
@@ -234,7 +241,7 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var candidate = new List<(int, double, Drawing)>(sequence);
|
||||
var candidate = new List<SequenceEntry>(sequence);
|
||||
Mutate(candidate, candidateRotations, random);
|
||||
|
||||
var placed = blf.Fill(candidate);
|
||||
@@ -266,5 +273,12 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
return countDiff * 10.0 + densityDiff;
|
||||
}
|
||||
|
||||
private static void ReportBest(IProgress<NestProgress> progress, List<Part> parts,
|
||||
Box workArea, string description)
|
||||
{
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
|
||||
description, isOverallBest: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user