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:
@@ -31,7 +31,7 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
|
||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
||||
@@ -44,7 +44,7 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
Nesting algorithms provide both a legacy single-plate API and a whole-job API. The legacy path centers on `NestEngineBase`, `DefaultNestEngine` (formerly `NestEngine`), and the global `NestEngineRegistry`. New job callers use immutable, ID-based contracts in `Jobs/`: `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named strategy without reading or changing the process-global registry.
|
||||
|
||||
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` and NFP/`AutoNester` retain legacy name-based helpers and are deliberately outside the runner path.
|
||||
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains legacy name-based helpers and is deliberately outside the runner path.
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. Existing desktop, CLI, and MCP callers remain on this compatibility path until separate migrations preserve their existing-plate, preview, and accept/cancel semantics.
|
||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
||||
@@ -53,7 +53,6 @@ Nesting algorithms provide both a legacy single-plate API and a whole-job API. T
|
||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
||||
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
||||
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
|
||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
||||
@@ -70,7 +69,7 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
||||
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
|
||||
|
||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
|
||||
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and multi-drawing auto-nesting through the active engine's `Nest()` (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
|
||||
|
||||
### OpenNest.Gpu (class library, depends on Core + Engine)
|
||||
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
|
||||
@@ -130,7 +129,7 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
## Key Patterns
|
||||
|
||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
|
||||
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
|
||||
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
|
||||
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
|
||||
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
|
||||
|
||||
@@ -597,7 +597,7 @@ static class NestConsole
|
||||
" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --autonest Use NFP-based mixed-part autonesting instead of linear fill"
|
||||
" --autonest Use mixed-part autonesting (engine Nest) instead of linear fill"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --keep-parts Don't clear existing parts before filling"
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
|
||||
/// reference point can be placed so the part stays entirely within the plate boundary.
|
||||
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
|
||||
/// </summary>
|
||||
public static class InnerFitPolygon
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the IFP for placing a part polygon inside a rectangular work area.
|
||||
/// The result is a polygon representing all valid reference point positions.
|
||||
/// </summary>
|
||||
public static Polygon Compute(Box workArea, Polygon partPolygon)
|
||||
{
|
||||
// Get the part's bounding box relative to its reference point (origin).
|
||||
var verts = partPolygon.Vertices;
|
||||
|
||||
if (verts.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
var minX = verts[0].X;
|
||||
var maxX = verts[0].X;
|
||||
var minY = verts[0].Y;
|
||||
var maxY = verts[0].Y;
|
||||
|
||||
for (var i = 1; i < verts.Count; i++)
|
||||
{
|
||||
if (verts[i].X < minX)
|
||||
minX = verts[i].X;
|
||||
if (verts[i].X > maxX)
|
||||
maxX = verts[i].X;
|
||||
if (verts[i].Y < minY)
|
||||
minY = verts[i].Y;
|
||||
if (verts[i].Y > maxY)
|
||||
maxY = verts[i].Y;
|
||||
}
|
||||
|
||||
// The IFP is the work area shrunk inward by the part's extent in each direction.
|
||||
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
|
||||
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
|
||||
var ifpLeft = workArea.X - minX;
|
||||
var ifpRight = workArea.Right - maxX;
|
||||
var ifpBottom = workArea.Y - minY;
|
||||
var ifpTop = workArea.Top - maxY;
|
||||
|
||||
// If the part doesn't fit, return an empty polygon.
|
||||
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
|
||||
return new Polygon();
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpTop));
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the feasible region for placing a part given already-placed parts.
|
||||
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
|
||||
/// Returns the polygon representing valid placement positions, or an empty
|
||||
/// polygon if no valid position exists.
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, PathsD nfpPaths)
|
||||
{
|
||||
if (ifp.Vertices.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
if (nfpPaths == null || nfpPaths.Count == 0)
|
||||
return ifp;
|
||||
|
||||
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
|
||||
var ifpPaths = new PathsD { ifpPath };
|
||||
|
||||
// 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();
|
||||
|
||||
// Find the polygon with the bottom-left-most point.
|
||||
// This ensures we pick the correct region for placement.
|
||||
PathD bestPath = null;
|
||||
var bestY = double.MaxValue;
|
||||
var bestX = double.MaxValue;
|
||||
|
||||
foreach (var path in feasible)
|
||||
{
|
||||
foreach (var pt in path)
|
||||
{
|
||||
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
|
||||
{
|
||||
bestY = pt.y;
|
||||
bestX = pt.x;
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// Returns Vector.Invalid if the polygon has no vertices.
|
||||
/// </summary>
|
||||
public static Vector FindBottomLeftPoint(Polygon polygon)
|
||||
{
|
||||
if (polygon.Vertices.Count == 0)
|
||||
return Vector.Invalid;
|
||||
|
||||
var best = polygon.Vertices[0];
|
||||
|
||||
for (var i = 1; i < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var v = polygon.Vertices[i];
|
||||
|
||||
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
|
||||
best = v;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,6 @@ namespace OpenNest
|
||||
[Description("Trying pairs..."), ShortName("Pairs")]
|
||||
Pairs,
|
||||
|
||||
[Description("Trying NFP..."), ShortName("NFP")]
|
||||
Nfp,
|
||||
|
||||
[Description("Trying extents..."), ShortName("Extents")]
|
||||
Extents,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ public class NestPhaseExtensionsTests
|
||||
[InlineData(NestPhase.Linear, "Trying rotations...")]
|
||||
[InlineData(NestPhase.RectBestFit, "Trying best fit...")]
|
||||
[InlineData(NestPhase.Pairs, "Trying pairs...")]
|
||||
[InlineData(NestPhase.Nfp, "Trying NFP...")]
|
||||
[InlineData(NestPhase.Extents, "Trying extents...")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void DisplayName_ReturnsDescription(NestPhase phase, string expected)
|
||||
@@ -18,7 +17,6 @@ public class NestPhaseExtensionsTests
|
||||
[InlineData(NestPhase.Linear, "Linear")]
|
||||
[InlineData(NestPhase.RectBestFit, "BestFit")]
|
||||
[InlineData(NestPhase.Pairs, "Pairs")]
|
||||
[InlineData(NestPhase.Nfp, "NFP")]
|
||||
[InlineData(NestPhase.Extents, "Extents")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void ShortName_ReturnsShortLabel(NestPhase phase, string expected)
|
||||
|
||||
Reference in New Issue
Block a user