Compare commits

...

10 Commits

Author SHA1 Message Date
2d1f2217e5 fix: guard IsHandleCreated in EditNestForm timer
Prevent InvalidOperationException when the timer fires before or
after the control handle is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:51 -04:00
ae88c34361 fix: prioritize width-fitting candidates in PairFiller strip mode
In strip mode, build candidate list entirely from pairs whose
ShortestSide fits the narrow work area dimension, sorted by
estimated tile count. Previously, the top-50 utilization cut
ran first, excluding good strip candidates like #183.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:45 -04:00
708d895a04 perf: remove automatic angle sweep in linear fill
Remove NeedsSweep that triggered a 5-degree sweep (36 angles) when
the work area was narrower than the part. Position matters more than
angle for narrow areas, and the base angles (bestRotation + 90deg)
cover the useful cases. ForceFullSweep still works for training.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:38 -04:00
884817c5f9 fix: normalize best-fit pairs to landscape and fix viewer size swap
Normalize pair bounding box to landscape (width >= height) in
PairEvaluator for consistent display and filtering. Fix
BestFitViewerForm where BoundingWidth/BoundingHeight were passed
in the wrong order to the plate Size constructor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:31 -04:00
cf1c5fe120 feat: integrate NFP optimization into nest engines and fill UI
Add Compactor.Settle and AutoNester.Optimize post-passes to
NestEngineBase.Nest, StripNestEngine, and PlateView.FillWithProgress
so all fill paths benefit from geometry-aware compaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:24 -04:00
a04586f7df 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>
2026-03-20 14:43:18 -04:00
069e966453 feat: add Compactor.Settle for iterative compaction
Add Settle method that repeatedly pushes parts left then down until
total movement falls below a threshold. Replaces manual single-pass
push calls for more consistent gap closure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:43:10 -04:00
d9d275b675 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>
2026-03-20 14:43:04 -04:00
9411dd0fdd refactor: extract PlacedPart/SequenceEntry types, add IFP caching
Move PlacedPart to its own file. Replace tuple-based sequences with
SequenceEntry struct for clarity. Add IProgress parameter to
INestOptimizer. Add IFP caching to NfpCache to avoid recomputing
inner fit polygons for the same drawing/rotation/workArea.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:42:50 -04:00
facd07d7de feat: add Box.Translate and improve NFP/IFP geometry APIs
Add immutable Translate methods to Box. Make NoFitPolygon
ToClipperPath/FromClipperPath public with optional offset parameter.
Refactor InnerFitPolygon.ComputeFeasibleRegion to accept PathsD
directly, letting Clipper2 handle implicit union. Add UpdateBounds
calls after polygon construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:42:43 -04:00
22 changed files with 464 additions and 137 deletions

View File

@@ -74,6 +74,16 @@ namespace OpenNest.Geometry
Location += voffset;
}
public Box Translate(double x, double y)
{
return new Box(X + x, Y + y, Width, Length);
}
public Box Translate(Vector offset)
{
return new Box(X + offset.X, Y + offset.Y, Width, Length);
}
public double Left
{
get { return X; }

View File

@@ -52,6 +52,7 @@ namespace OpenNest.Geometry
result.Vertices.Add(new Vector(ifpRight, ifpTop));
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
result.Close();
result.UpdateBounds();
return result;
}
@@ -62,36 +63,20 @@ namespace OpenNest.Geometry
/// Returns the polygon representing valid placement positions, or an empty
/// polygon if no valid position exists.
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
{
if (ifp.Vertices.Count < 3)
return new Polygon();
if (nfps == null || nfps.Length == 0)
if (nfpPaths == null || nfpPaths.Count == 0)
return ifp;
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
var ifpPaths = new PathsD { ifpPath };
// Union all NFPs.
var nfpPaths = new PathsD();
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
{
var path = NoFitPolygon.ToClipperPath(nfp);
nfpPaths.Add(path);
}
}
if (nfpPaths.Count == 0)
return ifp;
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
// Subtract the NFP union from the IFP.
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
// Subtract the NFPs from the IFP.
// Clipper2 handles the implicit union of the clip paths.
var feasible = Clipper.Difference(ifpPaths, nfpPaths, FillRule.NonZero);
if (feasible.Count == 0)
return new Polygon();
@@ -118,6 +103,25 @@ namespace OpenNest.Geometry
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// (Legacy overload for backward compatibility).
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
{
if (nfps == null || nfps.Length == 0)
return ifp;
var nfpPaths = new PathsD(nfps.Length);
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
nfpPaths.Add(NoFitPolygon.ToClipperPath(nfp));
}
return ComputeFeasibleRegion(ifp, nfpPaths);
}
/// <summary>
/// Finds the bottom-left-most point on a polygon boundary.
/// "Bottom-left" means: minimize Y first, then minimize X.

View File

@@ -250,9 +250,9 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Converts an OpenNest Polygon to a Clipper2 PathD.
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
/// </summary>
internal static PathD ToClipperPath(Polygon polygon)
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
{
var path = new PathD();
var verts = polygon.Vertices;
@@ -263,7 +263,7 @@ namespace OpenNest.Geometry
n--;
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X, verts[i].Y));
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
@@ -271,7 +271,7 @@ namespace OpenNest.Geometry
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
internal static Polygon FromClipperPath(PathD path)
public static Polygon FromClipperPath(PathD path)
{
var polygon = new Polygon();
@@ -279,6 +279,7 @@ namespace OpenNest.Geometry
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
}

View File

@@ -1,6 +1,7 @@
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
@@ -67,6 +68,15 @@ namespace OpenNest.Engine.BestFit
var trueArea = drawing.Area * 2;
// Normalize to landscape (width >= height) for consistent display.
if (bestHeight > bestWidth)
{
var tmp = bestWidth;
bestWidth = bestHeight;
bestHeight = tmp;
bestRotation += Angle.HalfPI;
}
return new BestFitResult
{
Candidate = candidate,

View File

@@ -27,7 +27,7 @@ namespace OpenNest.Engine.Fill
var angles = new List<double>(baseAngles);
if (NeedsSweep(item, bestRotation, workArea))
if (ForceFullSweep)
AddSweepAngles(angles);
if (!ForceFullSweep && angles.Count > 2)
@@ -36,18 +36,6 @@ namespace OpenNest.Engine.Fill
return angles;
}
private bool NeedsSweep(NestItem item, double bestRotation, Box workArea)
{
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.Length);
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
return workAreaShortSide < partLongestSide || ForceFullSweep;
}
private static void AddSweepAngles(List<double> angles)
{
var step = Angle.ToRadians(5);

View File

@@ -174,5 +174,28 @@ namespace OpenNest.Engine.Fill
return 0;
}
/// <summary>
/// Repeatedly pushes parts left then down until total movement per
/// iteration falls below the given threshold.
/// </summary>
public static void Settle(List<Part> parts, Box workArea, double partSpacing,
double threshold = 0.01, int maxIterations = 20)
{
if (parts.Count < 2)
return;
var noObstacles = new List<Part>();
for (var i = 0; i < maxIterations; i++)
{
var moved = 0.0;
moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Left);
moved += Push(parts, noObstacles, workArea, partSpacing, PushDirection.Down);
if (moved < threshold)
break;
}
}
}
}

View File

@@ -168,35 +168,30 @@ namespace OpenNest.Engine.Fill
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
{
var kept = bestFits.Where(r => r.Keep).ToList();
var top = kept.Take(MaxTopCandidates).ToList();
var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
if (workShortSide < plateShortSide * 0.5)
{
var stripCandidates = bestFits
// Strip mode: prioritize candidates that fit the narrow dimension.
var stripCandidates = kept
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
&& r.Utilization >= MinStripUtilization)
.OrderByDescending(r => r.Utilization);
.ToList();
var existing = new HashSet<BestFitResult>(top);
SortByEstimatedCount(stripCandidates, workArea);
foreach (var r in stripCandidates)
{
if (top.Count >= MaxStripCandidates)
break;
if (existing.Add(r))
top.Add(r);
}
var top = stripCandidates.Take(MaxStripCandidates).ToList();
Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
return top;
}
SortByEstimatedCount(top, workArea);
var result = kept.Take(MaxTopCandidates).ToList();
SortByEstimatedCount(result, workArea);
return top;
return result;
}
private void SortByEstimatedCount(List<BestFitResult> candidates, Box workArea)

View File

@@ -1,4 +1,5 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -126,6 +127,12 @@ namespace OpenNest
}
}
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
return allParts;
}

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

@@ -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));
for (var i = 0; i < placedParts.Count; i++)
{
var placed = placedParts[i];
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
return result;
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;
}
}
/// <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; }
}
}

View File

@@ -1,5 +1,6 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Threading;
@@ -11,9 +12,9 @@ namespace OpenNest.Engine.Nfp
public class OptimizationResult
{
/// <summary>
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
/// The best placement sequence found.
/// </summary>
public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; }
public List<SequenceEntry> Sequence { get; set; }
/// <summary>
/// The score achieved by the best sequence.
@@ -34,6 +35,7 @@ namespace OpenNest.Engine.Nfp
{
OptimizationResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
IProgress<NestProgress> progress = null,
CancellationToken cancellation = default);
}
}

View File

@@ -14,6 +14,8 @@ namespace OpenNest.Engine.Nfp
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.
@@ -28,6 +30,26 @@ namespace OpenNest.Engine.Nfp
}
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>

View File

@@ -0,0 +1,15 @@
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; }
}
}

View File

@@ -0,0 +1,24 @@
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);
}
}
}

View File

@@ -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);
}
}
}

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;
}
}
}

View File

@@ -1,4 +1,5 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -92,12 +93,7 @@ namespace OpenNest
allParts.AddRange(shrinkResult.Parts);
// Compact placed parts toward the origin to close gaps.
if (allParts.Count > 1)
{
var noObstacles = new List<Part>();
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Left);
Compactor.Push(allParts, noObstacles, workArea, Plate.PartSpacing, PushDirection.Down);
}
Compactor.Settle(allParts, workArea, Plate.PartSpacing);
// Add unfilled items to pack list.
packItems.AddRange(shrinkResult.Leftovers);
@@ -127,6 +123,9 @@ namespace OpenNest
}
}
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
// Deduct placed quantities from original items.
foreach (var item in items)
{

View File

@@ -29,18 +29,16 @@ public class AngleCandidateBuilderTests
}
[Fact]
public void Build_NarrowWorkArea_ProducesMoreAngles()
public void Build_NarrowWorkArea_UsesBaseAnglesOnly()
{
var builder = new AngleCandidateBuilder();
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var wideArea = new Box(0, 0, 100, 100);
var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
var wideAngles = builder.Build(item, 0, wideArea);
var narrowAngles = builder.Build(item, 0, narrowArea);
var angles = builder.Build(item, 0, narrowArea);
Assert.True(narrowAngles.Count > wideAngles.Count,
$"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
// Without ForceFullSweep, narrow areas use only base angles (0° and 90°)
Assert.Equal(2, angles.Count);
}
[Fact]

View File

@@ -2,6 +2,7 @@
using OpenNest.CNC;
using OpenNest.Collections;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -955,8 +956,13 @@ namespace OpenNest.Controls
try
{
var engine = NestEngineRegistry.Create(Plate);
var spacing = Plate.PartSpacing;
var parts = await Task.Run(() =>
engine.Fill(groupParts, workArea, progress, cts.Token));
{
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
Compactor.Settle(result, workArea, spacing);
return AutoNester.Optimize(result, workArea, spacing);
});
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
{

View File

@@ -291,8 +291,8 @@ namespace OpenNest.Forms
cell.PartColor = partColor;
cell.Dock = DockStyle.Fill;
cell.Plate.Size = new Geometry.Size(
result.BoundingHeight,
result.BoundingWidth);
result.BoundingWidth,
result.BoundingHeight);
var parts = result.BuildParts(drawing);

View File

@@ -774,6 +774,9 @@ namespace OpenNest.Forms
private void drawingListUpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (!drawingListBox1.IsHandleCreated)
return;
drawingListBox1.Invoke(new MethodInvoker(() =>
{
drawingListBox1.Refresh();