feat: add AutoNester.Optimize post-pass and NfpNestEngine

Add Optimize method that re-places parts using NFP-based BLF, keeping
the result only if it improves density without losing parts. Fix
perimeter inflation to use correct offset side. Add NfpNestEngine
that wraps AutoNester for the registry. Register NFP engine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 14:43:18 -04:00
parent 069e966453
commit a04586f7df
3 changed files with 197 additions and 2 deletions

View File

@@ -20,6 +20,10 @@ namespace OpenNest
Register("Strip",
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
Register("NFP",
"NFP-based mixed-part nesting with simulated annealing",
plate => new NfpNestEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;

View File

@@ -1,8 +1,10 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
@@ -15,6 +17,7 @@ namespace OpenNest.Engine.Nfp
public static class AutoNester
{
public static List<Part> Nest(List<NestItem> items, Plate plate,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default)
{
var workArea = plate.WorkArea();
@@ -60,7 +63,7 @@ namespace OpenNest.Engine.Nfp
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, progress, cancellation);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
@@ -72,9 +75,129 @@ namespace OpenNest.Engine.Nfp
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
$"NFP: {parts.Count} parts, {result.Iterations} iterations", isOverallBest: true);
return parts;
}
/// <summary>
/// Re-places already-positioned parts using NFP-based BLF.
/// Returns the tighter layout if BLF improves density without losing parts.
/// </summary>
public static List<Part> Optimize(List<Part> parts, Plate plate)
{
return Optimize(parts, plate.WorkArea(), plate.PartSpacing);
}
/// <summary>
/// Re-places already-positioned parts using NFP-based BLF within the given work area.
/// Returns the tighter layout if BLF improves density without losing parts.
/// </summary>
public static List<Part> Optimize(List<Part> parts, Box workArea, double partSpacing)
{
if (parts == null || parts.Count < 2)
return parts;
var halfSpacing = partSpacing / 2.0;
var nfpCache = new NfpCache();
var registeredRotations = new HashSet<(int id, double rotation)>();
// Extract polygons for each unique drawing+rotation used by the placed parts.
foreach (var part in parts)
{
var drawing = part.BaseDrawing;
var rotation = part.Rotation;
var key = (drawing.Id, rotation);
if (registeredRotations.Contains(key))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
continue;
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
registeredRotations.Add(key);
}
if (registeredRotations.Count == 0)
return parts;
nfpCache.PreComputeAll();
// Build BLF sequence sorted by area descending (largest first packs best).
var sequence = parts
.OrderByDescending(p => p.BaseDrawing.Area)
.Select(p => new SequenceEntry(p.BaseDrawing.Id, p.Rotation, p.BaseDrawing))
.ToList();
var blf = new BottomLeftFill(workArea, nfpCache);
var placed = blf.Fill(sequence);
var optimized = BottomLeftFill.ToNestParts(placed);
// Only use the NFP result if it kept all parts and improved density.
if (optimized.Count < parts.Count)
{
Debug.WriteLine($"[AutoNest.Optimize] Rejected: placed {optimized.Count}/{parts.Count} parts");
return parts;
}
// Reject if any part landed outside the work area.
if (!AllPartsInBounds(optimized, workArea))
{
Debug.WriteLine("[AutoNest.Optimize] Rejected: parts outside work area");
return parts;
}
var originalScore = Fill.FillScore.Compute(parts, workArea);
var optimizedScore = Fill.FillScore.Compute(optimized, workArea);
if (optimizedScore > originalScore)
{
Debug.WriteLine($"[AutoNest.Optimize] Improved: density {originalScore.Density:P1} -> {optimizedScore.Density:P1}");
return optimized;
}
Debug.WriteLine($"[AutoNest.Optimize] No improvement: {originalScore.Density:P1} >= {optimizedScore.Density:P1}");
return parts;
}
private static bool AllPartsInBounds(List<Part> parts, Box workArea)
{
var logPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log");
var allInBounds = true;
// Append to the log that BLF already started
using var log = new StreamWriter(logPath, true);
log.WriteLine($"\n[Bounds] workArea: X={workArea.X} Y={workArea.Y} W={workArea.Width} H={workArea.Length} Right={workArea.Right} Top={workArea.Top}");
foreach (var part in parts)
{
var bb = part.BoundingBox;
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
var oob = outLeft || outBottom || outRight || outTop;
if (oob)
{
log.WriteLine($"[Bounds] OOB DrawingId={part.BaseDrawing.Id} \"{part.BaseDrawing.Name}\" loc=({part.Location.X:F4},{part.Location.Y:F4}) rot={part.Rotation:F3} bb=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4}) violations: {(outLeft ? "LEFT " : "")}{(outBottom ? "BOTTOM " : "")}{(outRight ? "RIGHT " : "")}{(outTop ? "TOP " : "")}");
allInBounds = false;
}
}
if (allInBounds)
log.WriteLine($"[Bounds] All {parts.Count} parts in bounds.");
return allInBounds;
}
/// <summary>
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
/// </summary>
@@ -98,7 +221,7 @@ namespace OpenNest.Engine.Nfp
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left);
inflated = offsetEntity as Shape ?? perimeter;
}
else

View File

@@ -0,0 +1,68 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest
{
public class NfpNestEngine : NestEngineBase
{
public NfpNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "NFP";
public override string Description => "NFP-based mixed-part nesting with simulated annealing";
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(groupParts, workArea, progress, token);
}
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.PackArea(box, items, progress, token);
}
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var parts = AutoNester.Nest(items, Plate, progress, token);
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
// NFP optimization pass — re-place parts using geometry-aware BLF.
parts = AutoNester.Optimize(parts, Plate);
// Deduct placed quantities from original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = parts.FindAll(p => p.BaseDrawing.Name == item.Drawing.Name).Count;
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return parts;
}
}
}