chore: remove unused NFP nesting code

Delete OpenNest.Engine/Nfp (AutoNester, BottomLeftFill, NfpCache,
SimulatedAnnealing, INestOptimizer, PlacedPart, SequenceEntry), the Core
InnerFitPolygon, and the NestPhase.Nfp member. None had callers outside the
folder: console --autonest and MCP autonest_plate call engine.Nest(), not
AutoNester.

Drop the Nfp cases from NestPhaseExtensionsTests, fix the --autonest help
text, and update CLAUDE.md. NoFitPolygon stays; BestFit pair evaluation
still uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-20 22:20:30 -04:00
co-authored by Claude Sonnet 5
parent 451876c02f
commit 02fc0ea3db
12 changed files with 5 additions and 1243 deletions
-3
View File
@@ -24,9 +24,6 @@ namespace OpenNest
[Description("Trying pairs..."), ShortName("Pairs")]
Pairs,
[Description("Trying NFP..."), ShortName("NFP")]
Nfp,
[Description("Trying extents..."), ShortName("Extents")]
Extents,
-329
View File
@@ -1,329 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
/// and simulated annealing optimization.
/// </summary>
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();
var halfSpacing = plate.PartSpacing / 2.0;
var nfpCache = new NfpCache();
var candidateRotations = new Dictionary<int, List<double>>();
// Extract perimeter polygons for each unique drawing.
foreach (var item in items)
{
var drawing = item.Drawing;
if (candidateRotations.ContainsKey(drawing.Id))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
{
Debug.WriteLine(
$"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"
);
continue;
}
// Compute candidate rotations for this drawing.
var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea);
candidateRotations[drawing.Id] = rotations;
// Register polygons at each candidate rotation.
foreach (var rotation in rotations)
{
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
}
}
if (candidateRotations.Count == 0)
return new List<Part>();
// Pre-compute all NFPs.
nfpCache.PreComputeAll();
Debug.WriteLine(
$"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"
);
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(
items,
workArea,
nfpCache,
candidateRotations,
progress,
cancellation
);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
// Final BLF placement with the best solution.
var blf = new BottomLeftFill(workArea, nfpCache);
var placedParts = blf.Fill(result.Sequence);
var parts = BottomLeftFill.ToNestParts(placedParts);
Debug.WriteLine(
$"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"
);
NestEngineBase.ReportProgress(
progress,
new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = $"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>
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
}
/// <summary>
/// Computes candidate rotation angles for a drawing.
/// </summary>
private static List<double> ComputeCandidateRotations(
NestItem item,
Polygon perimeterPolygon,
Box workArea
)
{
var rotations = new List<double> { 0 };
// Add hull-edge angles from the polygon itself.
var hullAngles = ComputeHullEdgeAngles(perimeterPolygon);
foreach (var angle in hullAngles)
{
if (!rotations.Any(r => r.IsEqualTo(angle)))
rotations.Add(angle);
}
// Add 90-degree rotation.
if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI)))
rotations.Add(Angle.HalfPI);
// For narrow work areas, add sweep angles.
var partBounds = perimeterPolygon.BoundingBox;
var partLongest = System.Math.Max(partBounds.Width, partBounds.Length);
var workShort = System.Math.Min(workArea.Width, workArea.Length);
if (workShort < partLongest)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!rotations.Any(r => r.IsEqualTo(a)))
rotations.Add(a);
}
}
return rotations;
}
/// <summary>
/// Computes convex hull edge angles from a polygon for candidate rotations.
/// </summary>
private static List<double> ComputeHullEdgeAngles(Polygon polygon)
{
var angles = new List<double>();
if (polygon.Vertices.Count < 3)
return angles;
var hull = ConvexHull.Compute(polygon.Vertices);
var verts = hull.Vertices;
var n = hull.IsClosed() ? verts.Count - 1 : verts.Count;
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
if (dx * dx + dy * dy < Tolerance.Epsilon)
continue;
var angle = -System.Math.Atan2(dy, dx);
if (!angles.Any(a => a.IsEqualTo(angle)))
angles.Add(angle);
}
return angles;
}
/// <summary>
/// Creates a rotated copy of a polygon around the origin.
/// </summary>
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
}
}
}
-155
View File
@@ -1,155 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// NFP-based Bottom-Left Fill (BLF) placement engine.
/// Places parts one at a time using feasible regions computed from
/// the Inner-Fit Polygon minus the union of No-Fit Polygons.
/// </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;
public BottomLeftFill(Box workArea, NfpCache nfpCache)
{
this.workArea = workArea;
this.nfpCache = nfpCache;
}
/// <summary>
/// Places parts according to the given sequence using NFP-based BLF.
/// Returns the list of successfully placed parts with their positions.
/// </summary>
public List<PlacedPart> Fill(List<SequenceEntry> sequence)
{
var placedParts = new List<PlacedPart>();
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 ifp = nfpCache.GetIfp(entry.DrawingId, entry.Rotation, workArea);
if (ifp.Vertices.Count < 3)
{
log.WriteLine(
$"[BLF] DrawingId={entry.DrawingId} rot={entry.Rotation:F3} SKIPPED (IFP has {ifp.Vertices.Count} verts)"
);
continue;
}
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 = entry.DrawingId,
Rotation = entry.Rotation,
Position = point,
Drawing = entry.Drawing,
}
);
}
log.WriteLine($"[BLF] Total placed: {placedParts.Count}/{sequence.Count}");
return placedParts;
}
/// <summary>
/// Converts placed parts to OpenNest Part instances positioned on the plate.
/// </summary>
public static List<Part> ToNestParts(List<PlacedPart> placedParts)
{
var parts = new List<Part>(placedParts.Count);
foreach (var placed in placedParts)
{
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);
}
return parts;
}
/// <summary>
/// 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 PathsD ComputeNfpPaths(
List<PlacedPart> placedParts,
int drawingId,
double rotation,
Box ifpBounds
)
{
var nfpPaths = new PathsD(placedParts.Count);
for (var i = 0; i < placedParts.Count; i++)
{
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;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Result of a nest optimization run.
/// </summary>
public class OptimizationResult
{
/// <summary>
/// The best placement sequence found.
/// </summary>
public List<SequenceEntry> Sequence { get; set; }
/// <summary>
/// The score achieved by the best sequence.
/// </summary>
public FillScore Score { get; set; }
/// <summary>
/// Number of iterations performed.
/// </summary>
public int Iterations { get; set; }
}
/// <summary>
/// Interface for nest optimization algorithms that search for the best
/// part ordering and rotation to maximize plate utilization.
/// </summary>
public interface INestOptimizer
{
OptimizationResult Optimize(
List<NestItem> items,
Box workArea,
NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default
);
}
}
-164
View File
@@ -1,164 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Caches computed No-Fit Polygons keyed by (DrawingA.Id, RotationA, DrawingB.Id, RotationB).
/// NFPs are computed on first access and stored for reuse during optimization.
/// Thread-safe for concurrent reads after pre-computation.
/// </summary>
public class NfpCache
{
private readonly Dictionary<NfpKey, Polygon> cache = new Dictionary<NfpKey, Polygon>();
private readonly Dictionary<int, Dictionary<double, Polygon>> polygonCache =
new Dictionary<int, Dictionary<double, Polygon>>();
private readonly Dictionary<(int drawingId, double rotation), Polygon> ifpCache =
new Dictionary<(int drawingId, double rotation), Polygon>();
/// <summary>
/// Registers a pre-computed polygon for a drawing at a specific rotation.
/// Call this during initialization before computing NFPs.
/// </summary>
public void RegisterPolygon(int drawingId, double rotation, Polygon polygon)
{
if (!polygonCache.TryGetValue(drawingId, out var rotations))
{
rotations = new Dictionary<double, Polygon>();
polygonCache[drawingId] = rotations;
}
rotations[rotation] = polygon;
// Clear IFP cache if a polygon is updated (though usually they aren't).
ifpCache.Remove((drawingId, rotation));
}
/// <summary>
/// Gets or computes the IFP for a drawing at a specific rotation within a work area.
/// </summary>
public Polygon GetIfp(int drawingId, double rotation, Box workArea)
{
if (ifpCache.TryGetValue((drawingId, rotation), out var ifp))
return ifp;
var polygon = GetPolygon(drawingId, rotation);
if (polygon == null)
return new Polygon();
ifp = InnerFitPolygon.Compute(workArea, polygon);
ifpCache[(drawingId, rotation)] = ifp;
return ifp;
}
/// <summary>
/// Gets the polygon for a drawing at a specific rotation.
/// </summary>
public Polygon GetPolygon(int drawingId, double rotation)
{
if (polygonCache.TryGetValue(drawingId, out var rotations))
{
if (rotations.TryGetValue(rotation, out var polygon))
return polygon;
}
return null;
}
/// <summary>
/// Gets or computes the NFP between two drawings at their respective rotations.
/// The NFP is computed from the stationary polygon (drawingA at rotationA) and
/// the orbiting polygon (drawingB at rotationB).
/// </summary>
public Polygon Get(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
{
var key = new NfpKey(drawingIdA, rotationA, drawingIdB, rotationB);
if (cache.TryGetValue(key, out var nfp))
return nfp;
var polyA = GetPolygon(drawingIdA, rotationA);
var polyB = GetPolygon(drawingIdB, rotationB);
if (polyA == null || polyB == null)
return new Polygon();
nfp = NoFitPolygon.Compute(polyA, polyB);
cache[key] = nfp;
return nfp;
}
/// <summary>
/// Pre-computes all NFPs for every combination of registered polygons.
/// Call after all polygons are registered to front-load computation.
/// </summary>
public void PreComputeAll()
{
var entries = new List<(int drawingId, double rotation)>();
foreach (var kvp in polygonCache)
{
foreach (var rot in kvp.Value)
entries.Add((kvp.Key, rot.Key));
}
for (var i = 0; i < entries.Count; i++)
{
for (var j = 0; j < entries.Count; j++)
{
Get(
entries[i].drawingId,
entries[i].rotation,
entries[j].drawingId,
entries[j].rotation
);
}
}
}
/// <summary>
/// Number of cached NFP entries.
/// </summary>
public int Count => cache.Count;
private readonly struct NfpKey : IEquatable<NfpKey>
{
public readonly int DrawingIdA;
public readonly double RotationA;
public readonly int DrawingIdB;
public readonly double RotationB;
public NfpKey(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
{
DrawingIdA = drawingIdA;
RotationA = rotationA;
DrawingIdB = drawingIdB;
RotationB = rotationB;
}
public bool Equals(NfpKey other)
{
return DrawingIdA == other.DrawingIdA
&& RotationA == other.RotationA
&& DrawingIdB == other.DrawingIdB
&& RotationB == other.RotationB;
}
public override bool Equals(object obj) => obj is NfpKey key && Equals(key);
public override int GetHashCode()
{
unchecked
{
var hash = 17;
hash = hash * 31 + DrawingIdA;
hash = hash * 31 + RotationA.GetHashCode();
hash = hash * 31 + DrawingIdB;
hash = hash * 31 + RotationB.GetHashCode();
return hash;
}
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Represents a part that has been placed by the BLF algorithm.
/// </summary>
public class PlacedPart
{
public int DrawingId { get; set; }
public double Rotation { get; set; }
public Vector Position { get; set; }
public Drawing Drawing { get; set; }
}
}
-24
View File
@@ -1,24 +0,0 @@
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// An entry in a placement sequence — identifies which drawing to place and at what rotation.
/// </summary>
public readonly struct SequenceEntry
{
public int DrawingId { get; }
public double Rotation { get; }
public Drawing Drawing { get; }
public SequenceEntry(int drawingId, double rotation, Drawing drawing)
{
DrawingId = drawingId;
Rotation = rotation;
Drawing = drawing;
}
public SequenceEntry WithRotation(double rotation)
{
return new SequenceEntry(DrawingId, rotation, Drawing);
}
}
}
-348
View File
@@ -1,348 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Engine.Nfp
{
/// <summary>
/// Simulated annealing optimizer for NFP-based nesting.
/// Searches for the best part ordering and rotation to maximize plate utilization.
/// </summary>
public class SimulatedAnnealing : INestOptimizer
{
private const double DefaultCoolingRate = 0.995;
private const double DefaultMinTemperature = 0.1;
private const int DefaultMaxNoImprovement = 500;
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 entries,
// sorted by area descending.
var sequence = BuildInitialSequence(items, candidateRotations);
if (sequence.Count == 0)
return new OptimizationResult
{
Sequence = sequence,
Score = default,
Iterations = 0,
};
// Evaluate initial solution.
var blf = new BottomLeftFill(workArea, cache);
var bestPlaced = blf.Fill(sequence);
var bestScore = FillScore.Compute(BottomLeftFill.ToNestParts(bestPlaced), workArea);
var bestSequence = new List<SequenceEntry>(sequence);
var currentSequence = new List<SequenceEntry>(sequence);
var currentScore = bestScore;
// Calibrate initial temperature so ~80% of worse moves are accepted.
var initialTemp = CalibrateTemperature(
currentSequence,
workArea,
cache,
candidateRotations,
random
);
var temperature = initialTemp;
var noImprovement = 0;
var iteration = 0;
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<SequenceEntry>(currentSequence);
Mutate(candidate, candidateRotations, random);
var candidatePlaced = blf.Fill(candidate);
var candidateScore = FillScore.Compute(
BottomLeftFill.ToNestParts(candidatePlaced),
workArea
);
var delta = candidateScore.CompareTo(currentScore);
if (delta > 0)
{
// Better solution — always accept.
currentSequence = candidate;
currentScore = candidateScore;
if (currentScore > bestScore)
{
bestScore = currentScore;
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
{
noImprovement++;
}
}
else if (delta < 0)
{
// Worse solution — accept with probability based on temperature.
var scoreDiff = ScoreDifference(currentScore, candidateScore);
var acceptProb = System.Math.Exp(-scoreDiff / temperature);
if (random.NextDouble() < acceptProb)
{
currentSequence = candidate;
currentScore = candidateScore;
}
noImprovement++;
}
else
{
noImprovement++;
}
temperature *= DefaultCoolingRate;
}
Debug.WriteLine(
$"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}"
);
return new OptimizationResult
{
Sequence = bestSequence,
Score = bestScore,
Iterations = iteration,
};
}
/// <summary>
/// Builds the initial placement sequence sorted by drawing area descending.
/// Each NestItem is expanded by its quantity.
/// </summary>
private static List<SequenceEntry> BuildInitialSequence(
List<NestItem> items,
Dictionary<int, List<double>> candidateRotations
)
{
var sequence = new List<SequenceEntry>();
// Sort items by area descending.
var sorted = items.OrderByDescending(i => i.Drawing.Area).ToList();
foreach (var item in sorted)
{
var qty = item.Quantity > 0 ? item.Quantity : 1;
var rotation = 0.0;
if (
candidateRotations.TryGetValue(item.Drawing.Id, out var rotations)
&& rotations.Count > 0
)
rotation = rotations[0];
for (var i = 0; i < qty; i++)
sequence.Add(new SequenceEntry(item.Drawing.Id, rotation, item.Drawing));
}
return sequence;
}
/// <summary>
/// Applies a random mutation to the sequence.
/// </summary>
private static void Mutate(
List<SequenceEntry> sequence,
Dictionary<int, List<double>> candidateRotations,
Random random
)
{
if (sequence.Count < 2)
return;
var op = random.Next(3);
switch (op)
{
case 0: // Swap
MutateSwap(sequence, random);
break;
case 1: // Rotate
MutateRotate(sequence, candidateRotations, random);
break;
case 2: // Segment reverse
MutateReverse(sequence, random);
break;
}
}
/// <summary>
/// Swaps two random parts in the sequence.
/// </summary>
private static void MutateSwap(List<SequenceEntry> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
while (j == i && sequence.Count > 1)
j = random.Next(sequence.Count);
(sequence[i], sequence[j]) = (sequence[j], sequence[i]);
}
/// <summary>
/// Changes a random part's rotation to another candidate angle.
/// </summary>
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
)
return;
var newRotation = rotations[random.Next(rotations.Count)];
sequence[idx] = entry.WithRotation(newRotation);
}
/// <summary>
/// Reverses a random contiguous subsequence.
/// </summary>
private static void MutateReverse(List<SequenceEntry> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
if (i > j)
(i, j) = (j, i);
while (i < j)
{
(sequence[i], sequence[j]) = (sequence[j], sequence[i]);
i++;
j--;
}
}
/// <summary>
/// Calibrates the initial temperature by sampling random mutations and
/// measuring score differences. Sets temperature so ~80% of worse moves
/// are accepted initially.
/// </summary>
private static double CalibrateTemperature(
List<SequenceEntry> sequence,
Box workArea,
NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
Random random
)
{
const int samples = 20;
var deltas = new List<double>();
var blf = new BottomLeftFill(workArea, cache);
var basePlaced = blf.Fill(sequence);
var baseScore = FillScore.Compute(BottomLeftFill.ToNestParts(basePlaced), workArea);
for (var i = 0; i < samples; i++)
{
var candidate = new List<SequenceEntry>(sequence);
Mutate(candidate, candidateRotations, random);
var placed = blf.Fill(candidate);
var score = FillScore.Compute(BottomLeftFill.ToNestParts(placed), workArea);
var diff = ScoreDifference(baseScore, score);
if (diff > 0)
deltas.Add(diff);
}
if (deltas.Count == 0)
return 1.0;
// T = -avgDelta / ln(0.8) ≈ avgDelta * 4.48
var avgDelta = deltas.Average();
return -avgDelta / System.Math.Log(0.8);
}
/// <summary>
/// Computes a numeric difference between two scores for SA acceptance probability.
/// Uses a weighted combination of count and density.
/// </summary>
private static double ScoreDifference(FillScore better, FillScore worse)
{
// Weight count heavily (each part is worth 10 density points).
var countDiff = better.Count - worse.Count;
var densityDiff = better.Density - worse.Density;
return countDiff * 10.0 + densityDiff;
}
private static void ReportBest(
IProgress<NestProgress> progress,
List<Part> parts,
Box workArea,
string description
)
{
NestEngineBase.ReportProgress(
progress,
new ProgressReport
{
Phase = NestPhase.Nfp,
PlateNumber = 0,
Parts = parts,
WorkArea = workArea,
Description = description,
IsOverallBest = true,
}
);
}
}
}