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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
68
OpenNest.Engine/NfpNestEngine.cs
Normal file
68
OpenNest.Engine/NfpNestEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user