docs: revise lead-in UI spec with external/internal split and LayerType tagging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
223
OpenNest.Engine/AutoNester.cs
Normal file
223
OpenNest.Engine/AutoNester.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <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,
|
||||
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, 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");
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
|
||||
/// </summary>
|
||||
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
Shape inflated;
|
||||
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
|
||||
inflated = offsetEntity as Shape ?? perimeter;
|
||||
}
|
||||
else
|
||||
{
|
||||
inflated = perimeter;
|
||||
}
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
// Normalize: move reference point to origin.
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return 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)
|
||||
{
|
||||
if (angle.IsEqualTo(0))
|
||||
return polygon;
|
||||
|
||||
var result = new Polygon();
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
result.Vertices.Add(new Vector(
|
||||
v.X * cos - v.Y * sin,
|
||||
v.X * sin + v.Y * cos));
|
||||
}
|
||||
|
||||
// Re-normalize to origin.
|
||||
result.UpdateBounds();
|
||||
var bb = result.BoundingBox;
|
||||
result.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit
|
||||
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
|
||||
|
||||
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
|
||||
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
|
||||
|
||||
public static List<BestFitResult> GetOrCompute(
|
||||
Drawing drawing, double plateWidth, double plateHeight,
|
||||
@@ -24,6 +25,7 @@ namespace OpenNest.Engine.BestFit
|
||||
return cached;
|
||||
|
||||
IPairEvaluator evaluator = null;
|
||||
ISlideComputer slideComputer = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -33,13 +35,107 @@ namespace OpenNest.Engine.BestFit
|
||||
catch { /* fall back to default evaluator */ }
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator);
|
||||
if (CreateSlideComputer != null)
|
||||
{
|
||||
try { slideComputer = CreateSlideComputer(); }
|
||||
catch { /* fall back to CPU slide computation */ }
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||
var results = finder.FindBestFits(drawing, spacing, StepSize);
|
||||
|
||||
_cache.TryAdd(key, results);
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
// Slide computer is managed by the factory as a singleton — don't dispose here
|
||||
}
|
||||
}
|
||||
|
||||
public static void ComputeForSizes(
|
||||
Drawing drawing, double spacing,
|
||||
IEnumerable<(double Width, double Height)> plateSizes)
|
||||
{
|
||||
// Skip sizes that are already cached.
|
||||
var needed = new List<(double Width, double Height)>();
|
||||
foreach (var size in plateSizes)
|
||||
{
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
if (!_cache.ContainsKey(key))
|
||||
needed.Add(size);
|
||||
}
|
||||
|
||||
if (needed.Count == 0)
|
||||
return;
|
||||
|
||||
// Find the largest plate to use for the initial computation — this
|
||||
// keeps the filter maximally permissive so we don't discard results
|
||||
// that a smaller plate might still use after re-filtering.
|
||||
var maxWidth = 0.0;
|
||||
var maxHeight = 0.0;
|
||||
foreach (var size in needed)
|
||||
{
|
||||
if (size.Width > maxWidth) maxWidth = size.Width;
|
||||
if (size.Height > maxHeight) maxHeight = size.Height;
|
||||
}
|
||||
|
||||
IPairEvaluator evaluator = null;
|
||||
ISlideComputer slideComputer = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (CreateEvaluator != null)
|
||||
{
|
||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
||||
catch { /* fall back to default evaluator */ }
|
||||
}
|
||||
|
||||
if (CreateSlideComputer != null)
|
||||
{
|
||||
try { slideComputer = CreateSlideComputer(); }
|
||||
catch { /* fall back to CPU slide computation */ }
|
||||
}
|
||||
|
||||
// Compute candidates and evaluate once with the largest plate.
|
||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
|
||||
|
||||
// Cache a filtered copy for each plate size.
|
||||
foreach (var size in needed)
|
||||
{
|
||||
var filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = size.Width,
|
||||
MaxPlateHeight = size.Height
|
||||
};
|
||||
|
||||
var copy = new List<BestFitResult>(baseResults.Count);
|
||||
for (var i = 0; i < baseResults.Count; i++)
|
||||
{
|
||||
var r = baseResults[i];
|
||||
copy.Add(new BestFitResult
|
||||
{
|
||||
Candidate = r.Candidate,
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason
|
||||
});
|
||||
}
|
||||
|
||||
filter.Apply(copy);
|
||||
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
_cache.TryAdd(key, copy);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
}
|
||||
@@ -54,6 +150,28 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
public static void Populate(Drawing drawing, double plateWidth, double plateHeight,
|
||||
double spacing, List<BestFitResult> results)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
return;
|
||||
|
||||
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
|
||||
_cache.TryAdd(key, results);
|
||||
}
|
||||
|
||||
public static Dictionary<(double PlateWidth, double PlateHeight, double Spacing), List<BestFitResult>>
|
||||
GetAllForDrawing(Drawing drawing)
|
||||
{
|
||||
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
||||
foreach (var kvp in _cache)
|
||||
{
|
||||
if (ReferenceEquals(kvp.Key.Drawing, drawing))
|
||||
result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
|
||||
@@ -12,15 +12,21 @@ namespace OpenNest.Engine.BestFit
|
||||
public class BestFitFinder
|
||||
{
|
||||
private readonly IPairEvaluator _evaluator;
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
private readonly BestFitFilter _filter;
|
||||
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
||||
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
||||
{
|
||||
_evaluator = evaluator ?? new PairEvaluator();
|
||||
_slideComputer = slideComputer;
|
||||
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
||||
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||
_filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = maxPlateWidth,
|
||||
MaxPlateHeight = maxPlateHeight
|
||||
MaxPlateHeight = maxPlateHeight,
|
||||
MaxAspectRatio = System.Math.Max(5.0, plateAspect)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +84,7 @@ namespace OpenNest.Engine.BestFit
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
|
||||
}
|
||||
|
||||
return strategies;
|
||||
@@ -102,6 +108,7 @@ namespace OpenNest.Engine.BestFit
|
||||
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
|
||||
}
|
||||
|
||||
angles.Sort();
|
||||
return angles;
|
||||
}
|
||||
|
||||
@@ -109,14 +116,30 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
|
||||
var points = new List<Vector>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(0.01);
|
||||
points.AddRange(polygon.Vertices);
|
||||
// Extract key points from original geometry — line endpoints
|
||||
// plus arc endpoints and cardinal extreme points. This avoids
|
||||
// tessellating arcs into many chords that flood the hull with
|
||||
// near-duplicate edge angles.
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
points.Add(arc.StartPoint());
|
||||
points.Add(arc.EndPoint());
|
||||
AddArcExtremes(points, arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (points.Count < 3)
|
||||
@@ -143,13 +166,49 @@ namespace OpenNest.Engine.BestFit
|
||||
return hullAngles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
|
||||
/// if they fall within the arc's angular span.
|
||||
/// </summary>
|
||||
private static void AddArcExtremes(List<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
|
||||
if (arc.IsReversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
// Right (0°)
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
|
||||
// Top (90°)
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
|
||||
// Left (180°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
|
||||
// Bottom (270°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum angular separation (radians) between hull-derived rotation candidates.
|
||||
/// Tessellated arcs produce many hull edges with nearly identical angles;
|
||||
/// a 1° threshold collapses those into a single representative.
|
||||
/// </summary>
|
||||
private const double AngleTolerance = System.Math.PI / 36; // 5 degrees
|
||||
|
||||
private static void AddUniqueAngle(List<double> angles, double angle)
|
||||
{
|
||||
angle = Angle.NormalizeRad(angle);
|
||||
|
||||
foreach (var existing in angles)
|
||||
{
|
||||
if (existing.IsEqualTo(angle))
|
||||
if (existing.IsEqualTo(angle, AngleTolerance))
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace OpenNest.Engine.BestFit
|
||||
public bool Keep { get; set; }
|
||||
public string Reason { get; set; }
|
||||
public double TrueArea { get; set; }
|
||||
public List<double> HullAngles { get; set; }
|
||||
|
||||
public double Utilization
|
||||
{
|
||||
|
||||
38
OpenNest.Engine/BestFit/ISlideComputer.cs
Normal file
38
OpenNest.Engine/BestFit/ISlideComputer.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
/// <summary>
|
||||
/// Batches directional-distance computations for multiple offset positions.
|
||||
/// GPU implementations can process all offsets in a single kernel launch.
|
||||
/// </summary>
|
||||
public interface ISlideComputer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance for each offset position.
|
||||
/// </summary>
|
||||
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
|
||||
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
|
||||
/// <param name="movingTemplateSegments">Flat array [x1,y1,x2,y2, ...] for moving edges at origin.</param>
|
||||
/// <param name="movingCount">Number of line segments in movingTemplateSegments.</param>
|
||||
/// <param name="offsets">Flat array [dx,dy, dx,dy, ...] of translation offsets.</param>
|
||||
/// <param name="offsetCount">Number of offset positions.</param>
|
||||
/// <param name="direction">Push direction.</param>
|
||||
/// <returns>Array of minimum distances, one per offset position.</returns>
|
||||
double[] ComputeBatch(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
PushDirection direction);
|
||||
|
||||
/// <summary>
|
||||
/// Computes minimum directional distance for offsets with per-offset directions.
|
||||
/// Uploads segment data once for all offsets, reducing GPU round-trips.
|
||||
/// </summary>
|
||||
double[] ComputeBatchMultiDir(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
int[] directions);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
// Find optimal bounding rectangle via rotating calipers
|
||||
double bestArea, bestWidth, bestHeight, bestRotation;
|
||||
List<double> hullAngles = null;
|
||||
|
||||
if (allPoints.Count >= 3)
|
||||
{
|
||||
@@ -51,6 +52,7 @@ namespace OpenNest.Engine.BestFit
|
||||
bestWidth = result.Width;
|
||||
bestHeight = result.Height;
|
||||
bestRotation = result.Angle;
|
||||
hullAngles = RotationAnalysis.GetHullEdgeAngles(hull);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -59,6 +61,7 @@ namespace OpenNest.Engine.BestFit
|
||||
bestWidth = combinedBox.Width;
|
||||
bestHeight = combinedBox.Length;
|
||||
bestRotation = 0;
|
||||
hullAngles = new List<double> { 0 };
|
||||
}
|
||||
|
||||
var trueArea = drawing.Area * 2;
|
||||
@@ -71,6 +74,7 @@ namespace OpenNest.Engine.BestFit
|
||||
BoundingHeight = bestHeight,
|
||||
OptimalRotation = bestRotation,
|
||||
TrueArea = trueArea,
|
||||
HullAngles = hullAngles,
|
||||
Keep = !overlaps,
|
||||
Reason = overlaps ? "Overlap detected" : "Valid"
|
||||
};
|
||||
@@ -99,7 +103,7 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
shapes.ForEach(s => s.Offset(part.Location));
|
||||
return shapes;
|
||||
}
|
||||
@@ -108,7 +112,7 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
var points = new List<Vector>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class RotationSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description)
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
|
||||
private static readonly PushDirection[] AllDirections =
|
||||
{
|
||||
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
|
||||
};
|
||||
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description,
|
||||
ISlideComputer slideComputer = null)
|
||||
{
|
||||
Part2Rotation = part2Rotation;
|
||||
Type = type;
|
||||
Description = description;
|
||||
_slideComputer = slideComputer;
|
||||
}
|
||||
|
||||
public double Part2Rotation { get; }
|
||||
@@ -23,43 +33,64 @@ namespace OpenNest.Engine.BestFit
|
||||
var part1 = Part.CreateAtOrigin(drawing);
|
||||
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
|
||||
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
|
||||
// Collect offsets and directions across all 4 axes
|
||||
var allDx = new List<double>();
|
||||
var allDy = new List<double>();
|
||||
var allDirs = new List<PushDirection>();
|
||||
|
||||
foreach (var pushDir in AllDirections)
|
||||
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
|
||||
|
||||
if (allDx.Count == 0)
|
||||
return candidates;
|
||||
|
||||
// Compute all distances — single GPU dispatch or CPU loop
|
||||
var distances = ComputeAllDistances(
|
||||
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
|
||||
|
||||
// Create candidates from valid results
|
||||
var testNumber = 0;
|
||||
|
||||
// Try pushing left (horizontal slide)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Left, candidates, ref testNumber);
|
||||
for (var i = 0; i < allDx.Count; i++)
|
||||
{
|
||||
var slideDist = distances[i];
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
// Try pushing down (vertical slide)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Down, candidates, ref testNumber);
|
||||
var dx = allDx[i];
|
||||
var dy = allDy[i];
|
||||
var pushVector = GetPushVector(allDirs[i], slideDist);
|
||||
var finalPosition = new Vector(
|
||||
part2Template.Location.X + dx + pushVector.X,
|
||||
part2Template.Location.Y + dy + pushVector.Y);
|
||||
|
||||
// Try pushing right (approach from left — finds concave interlocking)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Right, candidates, ref testNumber);
|
||||
|
||||
// Try pushing up (approach from below — finds concave interlocking)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Up, candidates, ref testNumber);
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private void GenerateCandidatesForAxis(
|
||||
Part part1, Part part2Template, Drawing drawing,
|
||||
double spacing, double stepSize, PushDirection pushDir,
|
||||
List<PairCandidate> candidates, ref int testNumber)
|
||||
private static void BuildOffsets(
|
||||
Box bbox1, Box bbox2, double spacing, double stepSize,
|
||||
PushDirection pushDir, List<double> allDx, List<double> allDy,
|
||||
List<PushDirection> allDirs)
|
||||
{
|
||||
const int CoarseMultiplier = 16;
|
||||
const int MaxRegions = 5;
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
var halfSpacing = spacing / 2;
|
||||
|
||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
||||
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
@@ -77,103 +108,124 @@ namespace OpenNest.Engine.BestFit
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
|
||||
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
// Start with the full range as a single region.
|
||||
var regions = new List<(double min, double max)> { (perpMin, perpMax) };
|
||||
var currentStep = stepSize * CoarseMultiplier;
|
||||
|
||||
// Iterative halving: coarse sweep, select top regions, narrow, repeat.
|
||||
while (currentStep > stepSize)
|
||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||
{
|
||||
var hits = new List<(double offset, double slideDist)>();
|
||||
allDx.Add(isHorizontalPush ? startPos : offset);
|
||||
allDy.Add(isHorizontalPush ? offset : startPos);
|
||||
allDirs.Add(pushDir);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (regionMin, regionMax) in regions)
|
||||
private double[] ComputeAllDistances(
|
||||
List<Line> part1Lines, List<Line> part2TemplateLines,
|
||||
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
|
||||
{
|
||||
var count = allDx.Count;
|
||||
|
||||
if (_slideComputer != null)
|
||||
{
|
||||
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
|
||||
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
|
||||
var offsets = new double[count * 2];
|
||||
var directions = new int[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var alignedStart = System.Math.Ceiling(regionMin / currentStep) * currentStep;
|
||||
|
||||
for (var offset = alignedStart; offset <= regionMax; offset += currentStep)
|
||||
{
|
||||
var slideDist = ComputeSlideDistance(
|
||||
part2Template, part1Lines, halfSpacing,
|
||||
offset, pushStartOffset, isHorizontalPush, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
hits.Add((offset, slideDist));
|
||||
}
|
||||
offsets[i * 2] = allDx[i];
|
||||
offsets[i * 2 + 1] = allDy[i];
|
||||
directions[i] = (int)allDirs[i];
|
||||
}
|
||||
|
||||
if (hits.Count == 0)
|
||||
return;
|
||||
|
||||
// Select top regions by tightest fit, deduplicating nearby hits.
|
||||
hits.Sort((a, b) => a.slideDist.CompareTo(b.slideDist));
|
||||
|
||||
var selectedOffsets = new List<double>();
|
||||
|
||||
foreach (var (offset, _) in hits)
|
||||
{
|
||||
var tooClose = false;
|
||||
|
||||
foreach (var selected in selectedOffsets)
|
||||
{
|
||||
if (System.Math.Abs(offset - selected) < currentStep)
|
||||
{
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose)
|
||||
{
|
||||
selectedOffsets.Add(offset);
|
||||
|
||||
if (selectedOffsets.Count >= MaxRegions)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build narrowed regions around selected offsets.
|
||||
regions = new List<(double min, double max)>();
|
||||
|
||||
foreach (var offset in selectedOffsets)
|
||||
{
|
||||
var regionMin = System.Math.Max(perpMin, offset - currentStep);
|
||||
var regionMax = System.Math.Min(perpMax, offset + currentStep);
|
||||
regions.Add((regionMin, regionMax));
|
||||
}
|
||||
|
||||
currentStep /= 2;
|
||||
return _slideComputer.ComputeBatchMultiDir(
|
||||
stationarySegments, part1Lines.Count,
|
||||
movingSegments, part2TemplateLines.Count,
|
||||
offsets, count, directions);
|
||||
}
|
||||
|
||||
// Final pass: sweep refined regions at stepSize, generating candidates.
|
||||
foreach (var (regionMin, regionMax) in regions)
|
||||
var results = new double[count];
|
||||
|
||||
// Pre-calculate moving vertices in local space.
|
||||
var movingVerticesLocal = new HashSet<Vector>();
|
||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
||||
{
|
||||
var alignedStart = System.Math.Ceiling(regionMin / stepSize) * stepSize;
|
||||
|
||||
for (var offset = alignedStart; offset <= regionMax; offset += stepSize)
|
||||
{
|
||||
var (slideDist, finalPosition) = ComputeSlideResult(
|
||||
part2Template, part1Lines, halfSpacing,
|
||||
offset, pushStartOffset, isHorizontalPush, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
}
|
||||
movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
|
||||
movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
|
||||
}
|
||||
var movingVerticesArray = movingVerticesLocal.ToArray();
|
||||
|
||||
// Pre-calculate stationary vertices in local space.
|
||||
var stationaryVerticesLocal = new HashSet<Vector>();
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
{
|
||||
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
|
||||
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
|
||||
}
|
||||
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
|
||||
|
||||
// Pre-sort stationary and moving edges for all 4 directions.
|
||||
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
|
||||
foreach (var dir in AllDirections)
|
||||
{
|
||||
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
|
||||
|
||||
if (dir == PushDirection.Left || dir == PushDirection.Right)
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
stationaryEdgesByDir[dir] = sEdges;
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
||||
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
|
||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
||||
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
movingEdgesByDir[dir] = mEdges;
|
||||
}
|
||||
|
||||
// Use Parallel.For for the heavy lifting.
|
||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||
{
|
||||
var dx = allDx[i];
|
||||
var dy = allDy[i];
|
||||
var dir = allDirs[i];
|
||||
var movingOffset = new Vector(dx, dy);
|
||||
|
||||
var sEdges = stationaryEdgesByDir[dir];
|
||||
var mEdges = movingEdgesByDir[dir];
|
||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Moving vertices -> Stationary edges
|
||||
foreach (var mv in movingVerticesArray)
|
||||
{
|
||||
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Stationary vertices -> Moving edges (translated)
|
||||
foreach (var sv in stationaryVerticesArray)
|
||||
{
|
||||
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector GetPushVector(PushDirection direction, double distance)
|
||||
@@ -187,48 +239,5 @@ namespace OpenNest.Engine.BestFit
|
||||
default: return Vector.Zero;
|
||||
}
|
||||
}
|
||||
private static double ComputeSlideDistance(
|
||||
Part part2Template, List<Line> part1Lines, double halfSpacing,
|
||||
double offset, double pushStartOffset,
|
||||
bool isHorizontalPush, PushDirection pushDir)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
|
||||
return Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
}
|
||||
|
||||
private static (double slideDist, Vector finalPosition) ComputeSlideResult(
|
||||
Part part2Template, List<Line> part1Lines, double halfSpacing,
|
||||
double offset, double pushStartOffset,
|
||||
bool isHorizontalPush, PushDirection pushDir)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
|
||||
var pushVector = GetPushVector(pushDir, slideDist);
|
||||
var finalPosition = part2.Location + pushVector;
|
||||
|
||||
return (slideDist, finalPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
OpenNest.Engine/Compactor.cs
Normal file
156
OpenNest.Engine/Compactor.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a group of parts left and down to close gaps after placement.
|
||||
/// Uses the same directional-distance logic as PlateView.PushSelected
|
||||
/// but operates on Part objects directly.
|
||||
/// </summary>
|
||||
public static class Compactor
|
||||
{
|
||||
private const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Compacts movingParts toward the bottom-left of the plate work area.
|
||||
/// Everything already on the plate (excluding movingParts) is treated
|
||||
/// as stationary obstacles.
|
||||
/// </summary>
|
||||
private const double RepeatThreshold = 0.01;
|
||||
private const int MaxIterations = 20;
|
||||
|
||||
public static void Compact(List<Part> movingParts, Plate plate)
|
||||
{
|
||||
if (movingParts == null || movingParts.Count == 0)
|
||||
return;
|
||||
|
||||
var savedPositions = SavePositions(movingParts);
|
||||
|
||||
// Try left-first.
|
||||
var leftFirst = CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
|
||||
|
||||
// Restore and try down-first.
|
||||
RestorePositions(movingParts, savedPositions);
|
||||
var downFirst = CompactLoop(movingParts, plate, PushDirection.Down, PushDirection.Left);
|
||||
|
||||
// Keep left-first if it traveled further.
|
||||
if (leftFirst > downFirst)
|
||||
{
|
||||
RestorePositions(movingParts, savedPositions);
|
||||
CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
|
||||
}
|
||||
}
|
||||
|
||||
private static double CompactLoop(List<Part> parts, Plate plate,
|
||||
PushDirection first, PushDirection second)
|
||||
{
|
||||
var total = 0.0;
|
||||
|
||||
for (var i = 0; i < MaxIterations; i++)
|
||||
{
|
||||
var a = Push(parts, plate, first);
|
||||
var b = Push(parts, plate, second);
|
||||
total += a + b;
|
||||
|
||||
if (a <= RepeatThreshold && b <= RepeatThreshold)
|
||||
break;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Vector[] SavePositions(List<Part> parts)
|
||||
{
|
||||
var positions = new Vector[parts.Count];
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
positions[i] = parts[i].Location;
|
||||
return positions;
|
||||
}
|
||||
|
||||
private static void RestorePositions(List<Part> parts, Vector[] positions)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
parts[i].Location = positions[i];
|
||||
}
|
||||
|
||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||
{
|
||||
var obstacleParts = plate.Parts
|
||||
.Where(p => !movingParts.Contains(p))
|
||||
.ToList();
|
||||
|
||||
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||
var obstacleLines = new List<Line>[obstacleParts.Count];
|
||||
|
||||
for (var i = 0; i < obstacleParts.Count; i++)
|
||||
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
var halfSpacing = plate.PartSpacing / 2;
|
||||
var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
|
||||
var workArea = plate.WorkArea();
|
||||
var distance = double.MaxValue;
|
||||
|
||||
// BB gap at which offset geometries are expected to be touching.
|
||||
var contactGap = (halfSpacing + ChordTolerance) * 2;
|
||||
|
||||
foreach (var moving in movingParts)
|
||||
{
|
||||
var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
|
||||
if (edgeDist <= 0)
|
||||
distance = 0;
|
||||
else if (edgeDist < distance)
|
||||
distance = edgeDist;
|
||||
|
||||
var movingBox = moving.BoundingBox;
|
||||
List<Line> movingLines = null;
|
||||
|
||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||
{
|
||||
// Use the reverse-direction gap to check if the obstacle is entirely
|
||||
// behind the moving part. The forward gap (gap < 0) is unreliable for
|
||||
// irregular shapes whose bounding boxes overlap even when the actual
|
||||
// geometry still has a valid contact in the push direction.
|
||||
var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
|
||||
if (reverseGap > 0)
|
||||
continue;
|
||||
|
||||
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||
if (gap >= distance)
|
||||
continue;
|
||||
|
||||
var perpOverlap = isHorizontal
|
||||
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
|
||||
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
|
||||
|
||||
if (!perpOverlap)
|
||||
continue;
|
||||
|
||||
movingLines ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
|
||||
|
||||
obstacleLines[i] ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
|
||||
|
||||
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
}
|
||||
}
|
||||
|
||||
if (distance < double.MaxValue && distance > 0)
|
||||
{
|
||||
var offset = SpatialQuery.DirectionToOffset(direction, distance);
|
||||
foreach (var moving in movingParts)
|
||||
moving.Offset(offset);
|
||||
return distance;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
697
OpenNest.Engine/DefaultNestEngine.cs
Normal file
697
OpenNest.Engine/DefaultNestEngine.cs
Normal file
@@ -0,0 +1,697 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.ML;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.RectanglePacking;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class DefaultNestEngine : NestEngineBase
|
||||
{
|
||||
public DefaultNestEngine(Plate plate) : base(plate) { }
|
||||
|
||||
public override string Name => "Default";
|
||||
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
|
||||
// Angles that have produced results across multiple Fill calls.
|
||||
// Populated after each Fill; used to prune subsequent fills.
|
||||
private readonly HashSet<double> knownGoodAngles = new();
|
||||
|
||||
// --- Public Fill API ---
|
||||
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
PhaseResults.Clear();
|
||||
AngleResults.Clear();
|
||||
var best = FindBestFill(item, workArea, progress, token);
|
||||
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var remainderSw = Stopwatch.StartNew();
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
remainderSw.Stop();
|
||||
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
|
||||
best = improved;
|
||||
WinnerPhase = NestPhase.Remainder;
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds));
|
||||
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null || best.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast fill count using linear fill with two angles plus the top cached
|
||||
/// pair candidates. Used by binary search to estimate capacity at a given
|
||||
/// box size without running the full Fill pipeline.
|
||||
/// </summary>
|
||||
private int QuickFillCount(Drawing drawing, Box testBox, double bestRotation)
|
||||
{
|
||||
var engine = new FillLinear(testBox, Plate.PartSpacing);
|
||||
var bestCount = 0;
|
||||
|
||||
// Single-part linear fills.
|
||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var h = engine.Fill(drawing, angle, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > bestCount)
|
||||
bestCount = h.Count;
|
||||
|
||||
var v = engine.Fill(drawing, angle, NestDirection.Vertical);
|
||||
if (v != null && v.Count > bestCount)
|
||||
bestCount = v.Count;
|
||||
}
|
||||
|
||||
// Top pair candidates — check if pairs tile better in this box.
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
drawing, Plate.Size.Width, Plate.Size.Length, Plate.PartSpacing);
|
||||
var topPairs = bestFits.Where(r => r.Keep).Take(3);
|
||||
|
||||
foreach (var pair in topPairs)
|
||||
{
|
||||
var pairParts = pair.BuildParts(drawing);
|
||||
var pairAngles = pair.HullAngles ?? new List<double> { 0 };
|
||||
var pairEngine = new FillLinear(testBox, Plate.PartSpacing);
|
||||
|
||||
foreach (var angle in pairAngles)
|
||||
{
|
||||
var pattern = BuildRotatedPattern(pairParts, angle);
|
||||
if (pattern.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var h = pairEngine.Fill(pattern, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > bestCount)
|
||||
bestCount = h.Count;
|
||||
|
||||
var v = pairEngine.Fill(pattern, NestDirection.Vertical);
|
||||
if (v != null && v.Count > bestCount)
|
||||
bestCount = v.Count;
|
||||
}
|
||||
}
|
||||
|
||||
return bestCount;
|
||||
}
|
||||
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (groupParts == null || groupParts.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
PhaseResults.Clear();
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
||||
var best = FillPattern(engine, groupParts, angles, workArea);
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
|
||||
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
|
||||
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
|
||||
if (groupParts.Count == 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing };
|
||||
var rectResult = FillRectangleBestFit(nestItem, workArea);
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0));
|
||||
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
{
|
||||
best = rectResult;
|
||||
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var pairResult = FillWithPairs(nestItem, workArea, token, progress);
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, 0));
|
||||
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
|
||||
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
{
|
||||
best = pairResult;
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
|
||||
best = improved;
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, 0));
|
||||
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best");
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
// --- Pack API ---
|
||||
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
|
||||
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
|
||||
|
||||
var engine = new PackBottomLeft(bin);
|
||||
engine.Pack(binItems);
|
||||
|
||||
return BinConverter.ToParts(bin, items);
|
||||
}
|
||||
|
||||
// --- FindBestFill: core orchestration ---
|
||||
|
||||
private List<Part> FindBestFill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress = null, CancellationToken token = default)
|
||||
{
|
||||
List<Part> best = null;
|
||||
|
||||
try
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
||||
var angles = BuildCandidateAngles(item, bestRotation, workArea);
|
||||
|
||||
// Pairs phase
|
||||
var pairSw = Stopwatch.StartNew();
|
||||
var pairResult = FillWithPairs(item, workArea, token, progress);
|
||||
pairSw.Stop();
|
||||
best = pairResult;
|
||||
var bestScore = FillScore.Compute(best, workArea);
|
||||
WinnerPhase = NestPhase.Pairs;
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds));
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts");
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Linear phase
|
||||
var linearSw = Stopwatch.StartNew();
|
||||
var bestLinearCount = 0;
|
||||
|
||||
for (var ai = 0; ai < angles.Count; ai++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var angle = angles[ai];
|
||||
var localEngine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal);
|
||||
var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical);
|
||||
|
||||
var angleDeg = Angle.ToDegrees(angle);
|
||||
if (h != null && h.Count > 0)
|
||||
{
|
||||
var scoreH = FillScore.Compute(h, workArea);
|
||||
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
|
||||
if (h.Count > bestLinearCount) bestLinearCount = h.Count;
|
||||
if (scoreH > bestScore)
|
||||
{
|
||||
best = h;
|
||||
bestScore = scoreH;
|
||||
WinnerPhase = NestPhase.Linear;
|
||||
}
|
||||
}
|
||||
if (v != null && v.Count > 0)
|
||||
{
|
||||
var scoreV = FillScore.Compute(v, workArea);
|
||||
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
|
||||
if (v.Count > bestLinearCount) bestLinearCount = v.Count;
|
||||
if (scoreV > bestScore)
|
||||
{
|
||||
best = v;
|
||||
bestScore = scoreV;
|
||||
WinnerPhase = NestPhase.Linear;
|
||||
}
|
||||
}
|
||||
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||
}
|
||||
|
||||
linearSw.Stop();
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
|
||||
|
||||
// Record productive angles for future fills.
|
||||
foreach (var ar in AngleResults)
|
||||
{
|
||||
if (ar.PartCount > 0)
|
||||
knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
|
||||
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// RectBestFit phase
|
||||
var rectSw = Stopwatch.StartNew();
|
||||
var rectResult = FillRectangleBestFit(item, workArea);
|
||||
rectSw.Stop();
|
||||
var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts");
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, rectSw.ElapsedMilliseconds));
|
||||
|
||||
if (rectScore > bestScore)
|
||||
{
|
||||
best = rectResult;
|
||||
WinnerPhase = NestPhase.RectBestFit;
|
||||
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.WriteLine("[FindBestFill] Cancelled, returning current best");
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
// --- Angle building ---
|
||||
|
||||
private List<double> BuildCandidateAngles(NestItem item, double bestRotation, Box workArea)
|
||||
{
|
||||
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
// When the work area is narrow relative to the part, sweep rotation
|
||||
// angles so we can find one that fits the part into the tight strip.
|
||||
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);
|
||||
var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep;
|
||||
|
||||
if (needsSweep)
|
||||
{
|
||||
var step = Angle.ToRadians(5);
|
||||
for (var a = 0.0; a < System.Math.PI; a += step)
|
||||
{
|
||||
if (!angles.Any(existing => existing.IsEqualTo(a)))
|
||||
angles.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
// When the work area triggers a full sweep (and we're not forcing it for training),
|
||||
// try ML angle prediction to reduce the sweep.
|
||||
if (!ForceFullAngleSweep && angles.Count > 2)
|
||||
{
|
||||
var features = FeatureExtractor.Extract(item.Drawing);
|
||||
if (features != null)
|
||||
{
|
||||
var predicted = AnglePredictor.PredictAngles(
|
||||
features, workArea.Width, workArea.Length);
|
||||
|
||||
if (predicted != null)
|
||||
{
|
||||
var mlAngles = new List<double>(predicted);
|
||||
|
||||
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
|
||||
mlAngles.Add(bestRotation);
|
||||
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
|
||||
mlAngles.Add(bestRotation + Angle.HalfPI);
|
||||
|
||||
Debug.WriteLine($"[BuildCandidateAngles] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
|
||||
angles = mlAngles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have known-good angles from previous fills, use only those
|
||||
// plus the defaults (bestRotation + 90°). This prunes the expensive
|
||||
// angle sweep after the first fill.
|
||||
if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep)
|
||||
{
|
||||
var pruned = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
foreach (var a in knownGoodAngles)
|
||||
{
|
||||
if (!pruned.Any(existing => existing.IsEqualTo(a)))
|
||||
pruned.Add(a);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
|
||||
return pruned;
|
||||
}
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
// --- Fill strategies ---
|
||||
|
||||
private List<Part> FillRectangleBestFit(NestItem item, Box workArea)
|
||||
{
|
||||
var binItem = BinConverter.ToItem(item, Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
||||
|
||||
var engine = new FillBestFit(bin);
|
||||
engine.Fill(binItem);
|
||||
|
||||
return BinConverter.ToParts(bin, new List<NestItem> { item });
|
||||
}
|
||||
|
||||
private List<Part> FillWithPairs(NestItem item, Box workArea,
|
||||
CancellationToken token = default, IProgress<NestProgress> progress = null)
|
||||
{
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
item.Drawing, Plate.Size.Width, Plate.Size.Length,
|
||||
Plate.PartSpacing);
|
||||
|
||||
var candidates = SelectPairCandidates(bestFits, workArea);
|
||||
var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" +
|
||||
$"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}";
|
||||
Debug.WriteLine(diagMsg);
|
||||
try { System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { }
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
var sinceImproved = 0;
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var result = candidates[i];
|
||||
var pairParts = result.BuildParts(item.Drawing);
|
||||
var angles = result.HullAngles;
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
if (filled != null && filled.Count > 0)
|
||||
{
|
||||
var score = FillScore.Compute(filled, workArea);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = filled;
|
||||
bestScore = score;
|
||||
sinceImproved = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
}
|
||||
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea,
|
||||
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
|
||||
|
||||
// Early exit: stop if we've tried enough candidates without improvement.
|
||||
if (i >= 9 && sinceImproved >= 10)
|
||||
{
|
||||
Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far");
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
try { System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { }
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects pair candidates to try for the given work area. Always includes
|
||||
/// the top 50 by area. For narrow work areas, also includes all pairs whose
|
||||
/// shortest side fits the strip width.
|
||||
/// </summary>
|
||||
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
|
||||
{
|
||||
var kept = bestFits.Where(r => r.Keep).ToList();
|
||||
var top = kept.Take(50).ToList();
|
||||
|
||||
var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
|
||||
var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length);
|
||||
|
||||
// When the work area is significantly narrower than the plate,
|
||||
// search ALL candidates (not just kept) for pairs that fit the
|
||||
// narrow dimension. Pairs rejected by aspect ratio for the full
|
||||
// plate may be exactly what's needed for a narrow remainder strip.
|
||||
if (workShortSide < plateShortSide * 0.5)
|
||||
{
|
||||
var stripCandidates = bestFits
|
||||
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
|
||||
&& r.Utilization >= 0.3)
|
||||
.OrderByDescending(r => r.Utilization);
|
||||
|
||||
var existing = new HashSet<BestFitResult>(top);
|
||||
|
||||
foreach (var r in stripCandidates)
|
||||
{
|
||||
if (top.Count >= 100)
|
||||
break;
|
||||
|
||||
if (existing.Add(r))
|
||||
top.Add(r);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
|
||||
}
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
// --- Pattern helpers ---
|
||||
|
||||
private Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||
{
|
||||
var pattern = new Pattern();
|
||||
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
||||
|
||||
foreach (var part in groupParts)
|
||||
{
|
||||
var clone = (Part)part.Clone();
|
||||
clone.UpdateBounds();
|
||||
|
||||
if (!angle.IsEqualTo(0))
|
||||
clone.Rotate(angle, center);
|
||||
|
||||
pattern.Parts.Add(clone);
|
||||
}
|
||||
|
||||
pattern.UpdateBounds();
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
var results = new System.Collections.Concurrent.ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||
|
||||
Parallel.ForEach(angles, angle =>
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > 0)
|
||||
results.Add((h, FillScore.Compute(h, workArea)));
|
||||
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
if (v != null && v.Count > 0)
|
||||
results.Add((v, FillScore.Compute(v, workArea)));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var res in results)
|
||||
{
|
||||
if (best == null || res.Score > bestScore)
|
||||
{
|
||||
best = res.Parts;
|
||||
bestScore = res.Score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Remainder improvement ---
|
||||
|
||||
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
|
||||
{
|
||||
if (currentBest == null || currentBest.Count < 3)
|
||||
return null;
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true);
|
||||
|
||||
if (IsBetterFill(hResult, best, workArea))
|
||||
best = hResult;
|
||||
|
||||
var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false);
|
||||
|
||||
if (IsBetterFill(vResult, best, workArea))
|
||||
best = vResult;
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> parts, bool horizontal)
|
||||
{
|
||||
if (parts == null || parts.Count < 3)
|
||||
return null;
|
||||
|
||||
var clusters = ClusterParts(parts, horizontal);
|
||||
|
||||
if (clusters.Count < 2)
|
||||
return null;
|
||||
|
||||
// Determine the mode (most common) cluster count, excluding the last cluster.
|
||||
var mainClusters = clusters.Take(clusters.Count - 1).ToList();
|
||||
var modeCount = mainClusters
|
||||
.GroupBy(c => c.Count)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First()
|
||||
.Key;
|
||||
|
||||
var lastCluster = clusters[clusters.Count - 1];
|
||||
|
||||
// Only attempt refill if the last cluster is smaller than the mode.
|
||||
if (lastCluster.Count >= modeCount)
|
||||
return null;
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} clusters: {clusters.Count}, mode: {modeCount}, last: {lastCluster.Count}");
|
||||
|
||||
// Build the main parts list (everything except the last cluster).
|
||||
var mainParts = clusters.Take(clusters.Count - 1).SelectMany(c => c).ToList();
|
||||
var mainBox = ((IEnumerable<IBoundable>)mainParts).GetBoundingBox();
|
||||
|
||||
// Compute the strip box from the main grid edge to the work area edge.
|
||||
Box stripBox;
|
||||
|
||||
if (horizontal)
|
||||
{
|
||||
var stripLeft = mainBox.Right + Plate.PartSpacing;
|
||||
var stripWidth = workArea.Right - stripLeft;
|
||||
|
||||
if (stripWidth <= 0)
|
||||
return null;
|
||||
|
||||
stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stripBottom = mainBox.Top + Plate.PartSpacing;
|
||||
var stripHeight = workArea.Top - stripBottom;
|
||||
|
||||
if (stripHeight <= 0)
|
||||
return null;
|
||||
|
||||
stripBox = new Box(workArea.X, stripBottom, workArea.Width, stripHeight);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Length:F1} at ({stripBox.X:F1},{stripBox.Y:F1})");
|
||||
|
||||
var stripParts = FindBestFill(item, stripBox);
|
||||
|
||||
if (stripParts == null || stripParts.Count <= lastCluster.Count)
|
||||
{
|
||||
Debug.WriteLine($"[TryStripRefill] No improvement: strip={stripParts?.Count ?? 0} vs oddball={lastCluster.Count}");
|
||||
return null;
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] Improvement: strip={stripParts.Count} vs oddball={lastCluster.Count}");
|
||||
|
||||
var combined = new List<Part>(mainParts.Count + stripParts.Count);
|
||||
combined.AddRange(mainParts);
|
||||
combined.AddRange(stripParts);
|
||||
return combined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups parts into positional clusters along the given axis.
|
||||
/// Parts whose center positions are separated by more than half
|
||||
/// the part dimension start a new cluster.
|
||||
/// </summary>
|
||||
private static List<List<Part>> ClusterParts(List<Part> parts, bool horizontal)
|
||||
{
|
||||
var sorted = horizontal
|
||||
? parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
|
||||
: parts.OrderBy(p => p.BoundingBox.Center.Y).ToList();
|
||||
|
||||
var refDim = horizontal
|
||||
? sorted.Max(p => p.BoundingBox.Width)
|
||||
: sorted.Max(p => p.BoundingBox.Length);
|
||||
var gapThreshold = refDim * 0.5;
|
||||
|
||||
var clusters = new List<List<Part>>();
|
||||
var current = new List<Part> { sorted[0] };
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prevCenter = horizontal
|
||||
? sorted[i - 1].BoundingBox.Center.X
|
||||
: sorted[i - 1].BoundingBox.Center.Y;
|
||||
var currCenter = horizontal
|
||||
? sorted[i].BoundingBox.Center.X
|
||||
: sorted[i].BoundingBox.Center.Y;
|
||||
|
||||
if (currCenter - prevCenter > gapThreshold)
|
||||
{
|
||||
clusters.Add(current);
|
||||
current = new List<Part>();
|
||||
}
|
||||
|
||||
current.Add(sorted[i]);
|
||||
}
|
||||
|
||||
clusters.Add(current);
|
||||
return clusters;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -77,17 +78,16 @@ namespace OpenNest
|
||||
{
|
||||
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
||||
var pushDir = GetPushDirection(direction);
|
||||
var opposite = Helper.OppositeDirection(pushDir);
|
||||
|
||||
var locationB = partA.Location + MakeOffset(direction, bboxDim);
|
||||
var locationBOffset = MakeOffset(direction, bboxDim);
|
||||
|
||||
var movingLines = boundary.GetLines(locationB, pushDir);
|
||||
var stationaryLines = boundary.GetLines(partA.Location, opposite);
|
||||
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir);
|
||||
// Use the most efficient array-based overload to avoid all allocations.
|
||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
|
||||
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
|
||||
pushDir);
|
||||
|
||||
var copyDist = ComputeCopyDistance(bboxDim, slideDistance);
|
||||
//System.Diagnostics.Debug.WriteLine($"[FindCopyDistance] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} locA={partA.Location} locB={locationB} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
|
||||
return copyDist;
|
||||
return ComputeCopyDistance(bboxDim, slideDistance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -103,11 +103,10 @@ namespace OpenNest
|
||||
|
||||
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
||||
var pushDir = GetPushDirection(direction);
|
||||
var opposite = Helper.OppositeDirection(pushDir);
|
||||
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
||||
|
||||
// Compute a starting offset large enough that every part-pair in
|
||||
// patternB has its offset geometry beyond patternA's offset geometry.
|
||||
// max(aUpper_i - bLower_j) = max(aUpper) - min(bLower).
|
||||
var maxUpper = double.MinValue;
|
||||
var minLower = double.MaxValue;
|
||||
|
||||
@@ -126,22 +125,28 @@ namespace OpenNest
|
||||
|
||||
var offset = MakeOffset(direction, startOffset);
|
||||
|
||||
// Pre-compute stationary lines for patternA parts.
|
||||
var stationaryCache = new List<Line>[patternA.Parts.Count];
|
||||
// Pre-cache edge arrays.
|
||||
var movingEdges = new (Vector start, Vector end)[patternA.Parts.Count][];
|
||||
var stationaryEdges = new (Vector start, Vector end)[patternA.Parts.Count][];
|
||||
|
||||
for (var i = 0; i < patternA.Parts.Count; i++)
|
||||
stationaryCache[i] = boundaries[i].GetLines(patternA.Parts[i].Location, opposite);
|
||||
{
|
||||
movingEdges[i] = boundaries[i].GetEdges(pushDir);
|
||||
stationaryEdges[i] = boundaries[i].GetEdges(opposite);
|
||||
}
|
||||
|
||||
var maxCopyDistance = 0.0;
|
||||
|
||||
for (var j = 0; j < patternA.Parts.Count; j++)
|
||||
{
|
||||
var locationB = patternA.Parts[j].Location + offset;
|
||||
var movingLines = boundaries[j].GetLines(locationB, pushDir);
|
||||
|
||||
for (var i = 0; i < patternA.Parts.Count; i++)
|
||||
{
|
||||
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryCache[i], pushDir);
|
||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||
movingEdges[j], locationB,
|
||||
stationaryEdges[i], patternA.Parts[i].Location,
|
||||
pushDir);
|
||||
|
||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||
continue;
|
||||
@@ -153,9 +158,7 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no pair interacted (shouldn't happen for real parts),
|
||||
// use the simple bounding-box + spacing distance.
|
||||
if (maxCopyDistance <= 0)
|
||||
if (maxCopyDistance < Tolerance.Epsilon)
|
||||
return bboxDim + PartSpacing;
|
||||
|
||||
return maxCopyDistance;
|
||||
@@ -166,19 +169,8 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
|
||||
{
|
||||
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
||||
var pushDir = GetPushDirection(direction);
|
||||
var opposite = Helper.OppositeDirection(pushDir);
|
||||
|
||||
var offset = MakeOffset(direction, bboxDim);
|
||||
|
||||
var movingLines = GetOffsetPatternLines(patternA, offset, boundary, pushDir);
|
||||
var stationaryLines = GetPatternLines(patternA, boundary, opposite);
|
||||
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir);
|
||||
|
||||
var copyDist = ComputeCopyDistance(bboxDim, slideDistance);
|
||||
//System.Diagnostics.Debug.WriteLine($"[FindSinglePartPatternCopyDist] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} patternParts={patternA.Parts.Count} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
|
||||
return copyDist;
|
||||
var template = patternA.Parts[0];
|
||||
return FindCopyDistance(template, direction, boundary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -330,54 +322,46 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively fills the work area. At depth 0, tiles the pattern along the
|
||||
/// primary axis, then recurses perpendicular. At depth 1, tiles and returns.
|
||||
/// Fills the work area by tiling the pattern along the primary axis to form
|
||||
/// a row, then tiling that row along the perpendicular axis to form a grid.
|
||||
/// After the grid is formed, fills the remaining strip with individual parts.
|
||||
/// </summary>
|
||||
private List<Part> FillRecursive(Pattern pattern, NestDirection direction, int depth)
|
||||
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
||||
{
|
||||
var perpAxis = PerpendicularAxis(direction);
|
||||
var boundaries = CreateBoundaries(pattern);
|
||||
var result = new List<Part>(pattern.Parts);
|
||||
result.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
if (depth == 0 && result.Count > pattern.Parts.Count)
|
||||
// Step 1: Tile along primary axis
|
||||
var row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
var rowPattern = new Pattern();
|
||||
rowPattern.Parts.AddRange(result);
|
||||
rowPattern.UpdateBounds();
|
||||
var perpAxis = PerpendicularAxis(direction);
|
||||
var gridResult = FillRecursive(rowPattern, perpAxis, depth + 1);
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Grid: {gridResult.Count} parts, rowSize={rowPattern.Parts.Count}, dir={direction}");
|
||||
|
||||
// Fill the remaining strip (after the last full row/column)
|
||||
// with individual parts from the seed pattern.
|
||||
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Remainder: {remaining.Count} parts");
|
||||
|
||||
if (remaining.Count > 0)
|
||||
gridResult.AddRange(remaining);
|
||||
|
||||
// Try one fewer row/column — the larger remainder strip may
|
||||
// fit more parts than the extra row contained.
|
||||
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] TryFewerRows: {fewerResult?.Count ?? -1} vs grid+remainder={gridResult.Count}");
|
||||
|
||||
if (fewerResult != null && fewerResult.Count > gridResult.Count)
|
||||
return fewerResult;
|
||||
|
||||
return gridResult;
|
||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
||||
return row;
|
||||
}
|
||||
|
||||
if (depth == 0)
|
||||
{
|
||||
// Single part didn't tile along primary — still try perpendicular.
|
||||
return FillRecursive(pattern, PerpendicularAxis(direction), depth + 1);
|
||||
}
|
||||
// Step 2: Build row pattern and tile along perpendicular axis
|
||||
var rowPattern = new Pattern();
|
||||
rowPattern.Parts.AddRange(row);
|
||||
rowPattern.UpdateBounds();
|
||||
|
||||
return result;
|
||||
var rowBoundaries = CreateBoundaries(rowPattern);
|
||||
var gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
||||
|
||||
// Step 3: Fill remaining strip
|
||||
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
|
||||
if (remaining.Count > 0)
|
||||
gridResult.AddRange(remaining);
|
||||
|
||||
// Step 4: Try fewer rows optimization
|
||||
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
|
||||
if (fewerResult != null && fewerResult.Count > gridResult.Count)
|
||||
return fewerResult;
|
||||
|
||||
return gridResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -390,37 +374,16 @@ namespace OpenNest
|
||||
{
|
||||
var rowPartCount = rowPattern.Parts.Count;
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] fullResult={fullResult.Count}, rowPartCount={rowPartCount}, tiledAxis={tiledAxis}");
|
||||
|
||||
// Need at least 2 rows for this to make sense (remove 1, keep 1+).
|
||||
if (fullResult.Count < rowPartCount * 2)
|
||||
{
|
||||
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove the last row's worth of parts.
|
||||
var fewerParts = new List<Part>(fullResult.Count - rowPartCount);
|
||||
|
||||
for (var i = 0; i < fullResult.Count - rowPartCount; i++)
|
||||
fewerParts.Add(fullResult[i]);
|
||||
|
||||
// Find the top/right edge of the kept parts for logging.
|
||||
var edge = double.MinValue;
|
||||
foreach (var part in fewerParts)
|
||||
{
|
||||
var e = tiledAxis == NestDirection.Vertical
|
||||
? part.BoundingBox.Top
|
||||
: part.BoundingBox.Right;
|
||||
if (e > edge) edge = e;
|
||||
}
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Kept {fewerParts.Count} parts, edge={edge:F2}, workArea={WorkArea}");
|
||||
|
||||
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
|
||||
|
||||
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)");
|
||||
|
||||
if (remaining.Count <= rowPartCount)
|
||||
return null;
|
||||
|
||||
@@ -438,7 +401,18 @@ namespace OpenNest
|
||||
List<Part> placedParts, Pattern seedPattern,
|
||||
NestDirection tiledAxis, NestDirection primaryAxis)
|
||||
{
|
||||
// Find the furthest edge of placed parts along the tiled axis.
|
||||
var placedEdge = FindPlacedEdge(placedParts, tiledAxis);
|
||||
var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis);
|
||||
|
||||
if (remainingStrip == null)
|
||||
return new List<Part>();
|
||||
|
||||
var rotations = BuildRotationSet(seedPattern);
|
||||
return FindBestFill(rotations, remainingStrip);
|
||||
}
|
||||
|
||||
private static double FindPlacedEdge(List<Part> placedParts, NestDirection tiledAxis)
|
||||
{
|
||||
var placedEdge = double.MinValue;
|
||||
|
||||
foreach (var part in placedParts)
|
||||
@@ -451,18 +425,20 @@ namespace OpenNest
|
||||
placedEdge = edge;
|
||||
}
|
||||
|
||||
// Build the remaining strip with a spacing gap from the last tiled row.
|
||||
Box remainingStrip;
|
||||
return placedEdge;
|
||||
}
|
||||
|
||||
private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis)
|
||||
{
|
||||
if (tiledAxis == NestDirection.Vertical)
|
||||
{
|
||||
var bottom = placedEdge + PartSpacing;
|
||||
var height = WorkArea.Top - bottom;
|
||||
|
||||
if (height <= Tolerance.Epsilon)
|
||||
return new List<Part>();
|
||||
return null;
|
||||
|
||||
remainingStrip = new Box(WorkArea.X, bottom, WorkArea.Width, height);
|
||||
return new Box(WorkArea.X, bottom, WorkArea.Width, height);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -470,18 +446,20 @@ namespace OpenNest
|
||||
var width = WorkArea.Right - left;
|
||||
|
||||
if (width <= Tolerance.Epsilon)
|
||||
return new List<Part>();
|
||||
return null;
|
||||
|
||||
remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Length);
|
||||
return new Box(left, WorkArea.Y, width, WorkArea.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rotation set: always try cardinal orientations (0° and 90°),
|
||||
// plus any unique rotations from the seed pattern.
|
||||
var filler = new FillLinear(remainingStrip, PartSpacing);
|
||||
List<Part> best = null;
|
||||
/// <summary>
|
||||
/// Builds a set of (drawing, rotation) candidates: cardinal orientations
|
||||
/// (0° and 90°) for each unique drawing, plus any seed pattern rotations
|
||||
/// not already covered.
|
||||
/// </summary>
|
||||
private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern)
|
||||
{
|
||||
var rotations = new List<(Drawing drawing, double rotation)>();
|
||||
|
||||
// Cardinal rotations for each unique drawing.
|
||||
var drawings = new List<Drawing>();
|
||||
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
@@ -507,7 +485,6 @@ namespace OpenNest
|
||||
rotations.Add((drawing, Angle.HalfPI));
|
||||
}
|
||||
|
||||
// Add seed pattern rotations that aren't already covered.
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var skip = false;
|
||||
@@ -525,13 +502,22 @@ namespace OpenNest
|
||||
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
|
||||
}
|
||||
|
||||
return rotations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries all rotation candidates in both directions in parallel, returns the
|
||||
/// fill with the most parts.
|
||||
/// </summary>
|
||||
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
|
||||
{
|
||||
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
|
||||
|
||||
System.Threading.Tasks.Parallel.ForEach(rotations, entry =>
|
||||
Parallel.ForEach(rotations, entry =>
|
||||
{
|
||||
var localFiller = new FillLinear(remainingStrip, PartSpacing);
|
||||
var h = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
|
||||
var v = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
|
||||
var filler = new FillLinear(strip, PartSpacing);
|
||||
var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
|
||||
var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
|
||||
|
||||
if (h != null && h.Count > 0)
|
||||
bag.Add(h);
|
||||
@@ -540,6 +526,8 @@ namespace OpenNest
|
||||
bag.Add(v);
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var candidate in bag)
|
||||
{
|
||||
if (best == null || candidate.Count > best.Count)
|
||||
@@ -604,7 +592,7 @@ namespace OpenNest
|
||||
basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon)
|
||||
return new List<Part>();
|
||||
|
||||
return FillRecursive(basePattern, primaryAxis, depth: 0);
|
||||
return FillGrid(basePattern, primaryAxis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -618,7 +606,7 @@ namespace OpenNest
|
||||
if (seed.Parts.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
return FillRecursive(seed, primaryAxis, depth: 0);
|
||||
return FillGrid(seed, primaryAxis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,39 +41,32 @@ namespace OpenNest
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var bboxArea = bbox.Area();
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea);
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea);
|
||||
|
||||
return new FillScore(parts.Count, usableRemnantArea, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
|
||||
/// by checking right and top edge strips between placed parts and the work area boundary.
|
||||
/// </summary>
|
||||
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
|
||||
private static double ComputeUsableRemnantArea(double maxRight, double maxTop, Box workArea)
|
||||
{
|
||||
var maxRight = double.MinValue;
|
||||
var maxTop = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Right > maxRight)
|
||||
maxRight = bb.Right;
|
||||
|
||||
if (bb.Top > maxTop)
|
||||
maxTop = bb.Top;
|
||||
}
|
||||
|
||||
var largest = 0.0;
|
||||
|
||||
// Right strip
|
||||
|
||||
119
OpenNest.Engine/ML/AnglePredictor.cs
Normal file
119
OpenNest.Engine/ML/AnglePredictor.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public static class AnglePredictor
|
||||
{
|
||||
private static InferenceSession _session;
|
||||
private static volatile bool _loadAttempted;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static List<double> PredictAngles(
|
||||
PartFeatures features, double sheetWidth, double sheetHeight,
|
||||
double threshold = 0.3)
|
||||
{
|
||||
var session = GetSession();
|
||||
if (session == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var input = new float[11];
|
||||
input[0] = (float)features.Area;
|
||||
input[1] = (float)features.Convexity;
|
||||
input[2] = (float)features.AspectRatio;
|
||||
input[3] = (float)features.BoundingBoxFill;
|
||||
input[4] = (float)features.Circularity;
|
||||
input[5] = (float)features.PerimeterToAreaRatio;
|
||||
input[6] = features.VertexCount;
|
||||
input[7] = (float)sheetWidth;
|
||||
input[8] = (float)sheetHeight;
|
||||
input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0));
|
||||
input[10] = (float)(features.Area / (sheetWidth * sheetHeight));
|
||||
|
||||
var tensor = new DenseTensor<float>(input, new[] { 1, 11 });
|
||||
var inputs = new List<NamedOnnxValue>
|
||||
{
|
||||
NamedOnnxValue.CreateFromTensor("features", tensor)
|
||||
};
|
||||
|
||||
using var results = session.Run(inputs);
|
||||
var probabilities = results.First().AsEnumerable<float>().ToArray();
|
||||
|
||||
var angles = new List<(double angleDeg, float prob)>();
|
||||
for (var i = 0; i < 36 && i < probabilities.Length; i++)
|
||||
{
|
||||
if (probabilities[i] >= threshold)
|
||||
angles.Add((i * 5.0, probabilities[i]));
|
||||
}
|
||||
|
||||
// Minimum 3 angles — take top by probability if fewer pass threshold.
|
||||
if (angles.Count < 3)
|
||||
{
|
||||
angles = probabilities
|
||||
.Select((p, i) => (angleDeg: i * 5.0, prob: p))
|
||||
.OrderByDescending(x => x.prob)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Always include 0 and 90 as safety fallback.
|
||||
var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList();
|
||||
|
||||
if (!result.Any(a => a.IsEqualTo(0)))
|
||||
result.Add(0);
|
||||
if (!result.Any(a => a.IsEqualTo(Angle.HalfPI)))
|
||||
result.Add(Angle.HalfPI);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static InferenceSession GetSession()
|
||||
{
|
||||
if (_loadAttempted)
|
||||
return _session;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_loadAttempted)
|
||||
return _session;
|
||||
|
||||
_loadAttempted = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
|
||||
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
|
||||
|
||||
if (!File.Exists(modelPath))
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
_session = new InferenceSession(modelPath);
|
||||
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
|
||||
}
|
||||
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
OpenNest.Engine/ML/BruteForceRunner.cs
Normal file
80
OpenNest.Engine/ML/BruteForceRunner.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public class BruteForceResult
|
||||
{
|
||||
public int PartCount { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
public long TimeMs { get; set; }
|
||||
public string LayoutData { get; set; }
|
||||
public List<Part> PlacedParts { get; set; }
|
||||
public string WinnerEngine { get; set; } = "";
|
||||
public long WinnerTimeMs { get; set; }
|
||||
public string RunnerUpEngine { get; set; } = "";
|
||||
public int RunnerUpPartCount { get; set; }
|
||||
public long RunnerUpTimeMs { get; set; }
|
||||
public string ThirdPlaceEngine { get; set; } = "";
|
||||
public int ThirdPlacePartCount { get; set; }
|
||||
public long ThirdPlaceTimeMs { get; set; }
|
||||
public List<AngleResult> AngleResults { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class BruteForceRunner
|
||||
{
|
||||
public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false)
|
||||
{
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
engine.ForceFullAngleSweep = forceFullAngleSweep;
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return null;
|
||||
|
||||
// Rank phase results — winner is explicit, runners-up sorted by count.
|
||||
var winner = engine.PhaseResults
|
||||
.FirstOrDefault(r => r.Phase == engine.WinnerPhase);
|
||||
var runnerUps = engine.PhaseResults
|
||||
.Where(r => r.PartCount > 0 && r.Phase != engine.WinnerPhase)
|
||||
.OrderByDescending(r => r.PartCount)
|
||||
.ToList();
|
||||
|
||||
return new BruteForceResult
|
||||
{
|
||||
PartCount = parts.Count,
|
||||
Utilization = CalculateUtilization(parts, plate.Area()),
|
||||
TimeMs = sw.ElapsedMilliseconds,
|
||||
LayoutData = SerializeLayout(parts),
|
||||
PlacedParts = parts,
|
||||
WinnerEngine = engine.WinnerPhase.ToString(),
|
||||
WinnerTimeMs = winner?.TimeMs ?? 0,
|
||||
RunnerUpEngine = runnerUps.Count > 0 ? runnerUps[0].Phase.ToString() : "",
|
||||
RunnerUpPartCount = runnerUps.Count > 0 ? runnerUps[0].PartCount : 0,
|
||||
RunnerUpTimeMs = runnerUps.Count > 0 ? runnerUps[0].TimeMs : 0,
|
||||
ThirdPlaceEngine = runnerUps.Count > 1 ? runnerUps[1].Phase.ToString() : "",
|
||||
ThirdPlacePartCount = runnerUps.Count > 1 ? runnerUps[1].PartCount : 0,
|
||||
ThirdPlaceTimeMs = runnerUps.Count > 1 ? runnerUps[1].TimeMs : 0,
|
||||
AngleResults = engine.AngleResults.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeLayout(List<Part> parts)
|
||||
{
|
||||
var data = parts.Select(p => new { X = p.Location.X, Y = p.Location.Y, R = p.Rotation }).ToList();
|
||||
return System.Text.Json.JsonSerializer.Serialize(data);
|
||||
}
|
||||
|
||||
private static double CalculateUtilization(List<Part> parts, double plateArea)
|
||||
{
|
||||
if (plateArea <= 0) return 0;
|
||||
return parts.Sum(p => p.BaseDrawing.Area) / plateArea;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
OpenNest.Engine/ML/FeatureExtractor.cs
Normal file
90
OpenNest.Engine/ML/FeatureExtractor.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public class PartFeatures
|
||||
{
|
||||
// --- Geometric Features ---
|
||||
public double Area { get; set; }
|
||||
public double Convexity { get; set; } // Area / Convex Hull Area
|
||||
public double AspectRatio { get; set; } // Width / Length
|
||||
public double BoundingBoxFill { get; set; } // Area / (Width * Length)
|
||||
public double Circularity { get; set; } // 4 * PI * Area / Perimeter^2
|
||||
public double PerimeterToAreaRatio { get; set; } // Perimeter / Area — spacing sensitivity
|
||||
public int VertexCount { get; set; }
|
||||
|
||||
// --- Normalized Bitmask (32x32 = 1024 features) ---
|
||||
public byte[] Bitmask { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{PerimeterToAreaRatio:F4},{VertexCount}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class FeatureExtractor
|
||||
{
|
||||
public static PartFeatures Extract(Drawing drawing)
|
||||
{
|
||||
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
var perimeter = profile.Perimeter;
|
||||
|
||||
if (perimeter == null) return null;
|
||||
|
||||
var polygon = perimeter.ToPolygonWithTolerance(0.01);
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
|
||||
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||
var hullArea = hull.Area();
|
||||
|
||||
var features = new PartFeatures
|
||||
{
|
||||
Area = drawing.Area,
|
||||
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
|
||||
AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0),
|
||||
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
||||
VertexCount = polygon.Vertices.Count,
|
||||
Bitmask = GenerateBitmask(polygon, 32)
|
||||
};
|
||||
|
||||
// Circularity = 4 * PI * Area / Perimeter^2
|
||||
var perimeterLen = polygon.Perimeter();
|
||||
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
|
||||
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
private static byte[] GenerateBitmask(Polygon polygon, int size)
|
||||
{
|
||||
var mask = new byte[size * size];
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
|
||||
for (int y = 0; y < size; y++)
|
||||
{
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
// Map grid coordinate (0..size) to bounding box coordinate
|
||||
var px = bb.Left + (x + 0.5) * (bb.Width / size);
|
||||
var py = bb.Bottom + (y + 0.5) * (bb.Length / size);
|
||||
|
||||
if (polygon.ContainsPoint(new Vector(px, py)))
|
||||
{
|
||||
mask[y * size + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
OpenNest.Engine/Models/.gitkeep
Normal file
0
OpenNest.Engine/Models/.gitkeep
Normal file
File diff suppressed because it is too large
Load Diff
319
OpenNest.Engine/NestEngineBase.cs
Normal file
319
OpenNest.Engine/NestEngineBase.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public abstract class NestEngineBase
|
||||
{
|
||||
protected NestEngineBase(Plate plate)
|
||||
{
|
||||
Plate = plate;
|
||||
}
|
||||
|
||||
public Plate Plate { get; set; }
|
||||
|
||||
public int PlateNumber { get; set; }
|
||||
|
||||
public NestDirection NestDirection { get; set; }
|
||||
|
||||
public NestPhase WinnerPhase { get; protected set; }
|
||||
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract string Description { get; }
|
||||
|
||||
// --- Virtual methods (side-effect-free, return parts) ---
|
||||
|
||||
public virtual List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
// --- Nest: multi-item strategy (virtual, side-effect-free) ---
|
||||
|
||||
public virtual List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
var allParts = new List<Part>();
|
||||
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
var packItems = items
|
||||
.Where(i => i.Quantity == 1)
|
||||
.ToList();
|
||||
|
||||
// Phase 1: Fill multi-quantity drawings sequentially.
|
||||
foreach (var item in fillItems)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0)
|
||||
continue;
|
||||
|
||||
var parts = FillExact(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
workArea, progress, token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(parts);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Pack single-quantity items into remaining space.
|
||||
packItems = packItems.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
var packParts = PackArea(workArea, packItems, progress, token);
|
||||
|
||||
if (packParts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(packParts);
|
||||
|
||||
foreach (var item in packItems)
|
||||
{
|
||||
var placed = packParts.Count(p =>
|
||||
p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
|
||||
protected static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
|
||||
{
|
||||
var hWidth = workArea.Right - usedBox.Right - spacing;
|
||||
var hStrip = hWidth > 0
|
||||
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
|
||||
: Box.Empty;
|
||||
|
||||
var vHeight = workArea.Top - usedBox.Top - spacing;
|
||||
var vStrip = vHeight > 0
|
||||
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
|
||||
: Box.Empty;
|
||||
|
||||
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
|
||||
}
|
||||
|
||||
// --- FillExact (non-virtual, delegates to virtual Fill) ---
|
||||
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
// --- Convenience overloads (mutate plate, return bool) ---
|
||||
|
||||
public bool Fill(NestItem item)
|
||||
{
|
||||
return Fill(item, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(NestItem item, Box workArea)
|
||||
{
|
||||
var parts = Fill(item, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts)
|
||||
{
|
||||
return Fill(groupParts, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts, Box workArea)
|
||||
{
|
||||
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Pack(List<NestItem> items)
|
||||
{
|
||||
var workArea = Plate.WorkArea();
|
||||
var parts = PackArea(workArea, items, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Protected utilities ---
|
||||
|
||||
protected static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description)
|
||||
{
|
||||
if (progress == null || best == null || best.Count == 0)
|
||||
return;
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
var bounds = best.GetBoundingBox();
|
||||
|
||||
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
|
||||
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
|
||||
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
|
||||
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
|
||||
Debug.WriteLine(msg);
|
||||
try { System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
BestParts = clonedParts,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
|
||||
protected string BuildProgressSummary()
|
||||
{
|
||||
if (PhaseResults.Count == 0)
|
||||
return null;
|
||||
|
||||
var parts = new List<string>(PhaseResults.Count);
|
||||
|
||||
foreach (var r in PhaseResults)
|
||||
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
}
|
||||
|
||||
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
|
||||
{
|
||||
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsBetterFill(candidate, current, workArea);
|
||||
}
|
||||
|
||||
protected static bool HasOverlaps(List<Part> parts, double spacing)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var box1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var box2 = parts[j].BoundingBox;
|
||||
|
||||
if (box1.Right < box2.Left || box2.Right < box1.Left ||
|
||||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
|
||||
continue;
|
||||
|
||||
List<Vector> pts;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out pts))
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
var b2 = parts[j].BoundingBox;
|
||||
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
|
||||
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
|
||||
$" intersections={pts?.Count ?? 0}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string FormatPhaseName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OpenNest.Engine/NestEngineInfo.cs
Normal file
18
OpenNest.Engine/NestEngineInfo.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class NestEngineInfo
|
||||
{
|
||||
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
Factory = factory;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public Func<Plate, NestEngineBase> Factory { get; }
|
||||
}
|
||||
}
|
||||
100
OpenNest.Engine/NestEngineRegistry.cs
Normal file
100
OpenNest.Engine/NestEngineRegistry.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class NestEngineRegistry
|
||||
{
|
||||
private static readonly List<NestEngineInfo> engines = new();
|
||||
|
||||
static NestEngineRegistry()
|
||||
{
|
||||
Register("Default",
|
||||
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
plate => new DefaultNestEngine(plate));
|
||||
|
||||
Register("Strip",
|
||||
"Strip-based nesting for mixed-drawing layouts",
|
||||
plate => new StripNestEngine(plate));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
|
||||
|
||||
public static string ActiveEngineName { get; set; } = "Default";
|
||||
|
||||
public static NestEngineBase Create(Plate plate)
|
||||
{
|
||||
var info = engines.FirstOrDefault(e =>
|
||||
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
|
||||
info = engines[0];
|
||||
}
|
||||
|
||||
return info.Factory(plate);
|
||||
}
|
||||
|
||||
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(new[] { typeof(Plate) });
|
||||
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a temporary instance to read Name and Description.
|
||||
try
|
||||
{
|
||||
var tempPlate = new Plate();
|
||||
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
|
||||
Register(instance.Name, instance.Description,
|
||||
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
|
||||
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,38 @@ namespace OpenNest
|
||||
Remainder
|
||||
}
|
||||
|
||||
public class PhaseResult
|
||||
{
|
||||
public NestPhase Phase { get; set; }
|
||||
public int PartCount { get; set; }
|
||||
public long TimeMs { get; set; }
|
||||
|
||||
public PhaseResult(NestPhase phase, int partCount, long timeMs)
|
||||
{
|
||||
Phase = phase;
|
||||
PartCount = partCount;
|
||||
TimeMs = timeMs;
|
||||
}
|
||||
}
|
||||
|
||||
public class AngleResult
|
||||
{
|
||||
public double AngleDeg { get; set; }
|
||||
public NestDirection Direction { get; set; }
|
||||
public int PartCount { get; set; }
|
||||
}
|
||||
|
||||
public class NestProgress
|
||||
{
|
||||
public NestPhase Phase { get; set; }
|
||||
public int PlateNumber { get; set; }
|
||||
public int BestPartCount { get; set; }
|
||||
public double BestDensity { get; set; }
|
||||
public double NestedWidth { get; set; }
|
||||
public double NestedLength { get; set; }
|
||||
public double NestedArea { get; set; }
|
||||
public double UsableRemnantArea { get; set; }
|
||||
public List<Part> BestParts { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Models\**" CopyToOutputDirectory="PreserveNewest" Condition="Exists('Models')" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -23,22 +23,26 @@ namespace OpenNest
|
||||
|
||||
public PartBoundary(Part part, double spacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
_polygons = new List<Polygon>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
if (perimeter != null)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape;
|
||||
var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape;
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
// Circumscribe arcs so polygon vertices are always outside
|
||||
// the true arc — guarantees the boundary never under-estimates.
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true);
|
||||
polygon.RemoveSelfIntersections();
|
||||
_polygons.Add(polygon);
|
||||
if (offsetEntity != null)
|
||||
{
|
||||
// Circumscribe arcs so polygon vertices are always outside
|
||||
// the true arc — guarantees the boundary never under-estimates.
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true);
|
||||
polygon.RemoveSelfIntersections();
|
||||
_polygons.Add(polygon);
|
||||
}
|
||||
}
|
||||
|
||||
PrecomputeDirectionalEdges(
|
||||
@@ -89,10 +93,10 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
leftEdges = left.ToArray();
|
||||
rightEdges = right.ToArray();
|
||||
upEdges = up.ToArray();
|
||||
downEdges = down.ToArray();
|
||||
leftEdges = left.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray();
|
||||
rightEdges = right.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray();
|
||||
upEdges = up.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray();
|
||||
downEdges = down.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -148,5 +152,14 @@ namespace OpenNest
|
||||
default: return _leftEdges;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-computed edge arrays for the given direction.
|
||||
/// These are in part-local coordinates (no translation applied).
|
||||
/// </summary>
|
||||
public (Vector start, Vector end)[] GetEdges(PushDirection direction)
|
||||
{
|
||||
return GetDirectionalEdges(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
120
OpenNest.Engine/PlateProcessor.cs
Normal file
120
OpenNest.Engine/PlateProcessor.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.RapidPlanning;
|
||||
using OpenNest.Engine.Sequencing;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public class PlateProcessor
|
||||
{
|
||||
public IPartSequencer Sequencer { get; set; }
|
||||
public ContourCuttingStrategy CuttingStrategy { get; set; }
|
||||
public IRapidPlanner RapidPlanner { get; set; }
|
||||
|
||||
public PlateResult Process(Plate plate)
|
||||
{
|
||||
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
||||
var results = new List<ProcessedPart>(sequenced.Count);
|
||||
var cutAreas = new List<Shape>();
|
||||
var currentPoint = PlateHelper.GetExitPoint(plate);
|
||||
|
||||
foreach (var sp in sequenced)
|
||||
{
|
||||
var part = sp.Part;
|
||||
|
||||
// Compute approach point in part-local space
|
||||
var localApproach = ToPartLocal(currentPoint, part);
|
||||
|
||||
Program processedProgram;
|
||||
Vector lastCutLocal;
|
||||
|
||||
if (!part.HasManualLeadIns && CuttingStrategy != null)
|
||||
{
|
||||
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
|
||||
processedProgram = cuttingResult.Program;
|
||||
lastCutLocal = cuttingResult.LastCutPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
processedProgram = part.Program;
|
||||
lastCutLocal = GetProgramEndPoint(part.Program);
|
||||
}
|
||||
|
||||
// Pierce point: program start point in plate space
|
||||
var pierceLocal = GetProgramStartPoint(part.Program);
|
||||
var piercePoint = ToPlateSpace(pierceLocal, part);
|
||||
|
||||
// Plan rapid from currentPoint to pierce point
|
||||
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
|
||||
|
||||
results.Add(new ProcessedPart
|
||||
{
|
||||
Part = part,
|
||||
ProcessedProgram = processedProgram,
|
||||
RapidPath = rapidPath
|
||||
});
|
||||
|
||||
// Update cut areas with part perimeter
|
||||
var perimeter = GetPartPerimeter(part);
|
||||
if (perimeter != null)
|
||||
cutAreas.Add(perimeter);
|
||||
|
||||
// Update current point to last cut point in plate space
|
||||
currentPoint = ToPlateSpace(lastCutLocal, part);
|
||||
}
|
||||
|
||||
return new PlateResult { Parts = results };
|
||||
}
|
||||
|
||||
private static Vector ToPartLocal(Vector platePoint, Part part)
|
||||
{
|
||||
return platePoint - part.Location;
|
||||
}
|
||||
|
||||
private static Vector ToPlateSpace(Vector localPoint, Part part)
|
||||
{
|
||||
return localPoint + part.Location;
|
||||
}
|
||||
|
||||
private static Vector GetProgramStartPoint(Program program)
|
||||
{
|
||||
if (program.Codes.Count == 0)
|
||||
return Vector.Zero;
|
||||
|
||||
var first = program.Codes[0];
|
||||
if (first is Motion motion)
|
||||
return motion.EndPoint;
|
||||
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private static Vector GetProgramEndPoint(Program program)
|
||||
{
|
||||
for (var i = program.Codes.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (program.Codes[i] is Motion motion)
|
||||
return motion.EndPoint;
|
||||
}
|
||||
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private static Shape GetPartPerimeter(Part part)
|
||||
{
|
||||
var entities = part.Program.ToGeometry();
|
||||
if (entities == null || entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
var perimeter = profile.Perimeter;
|
||||
if (perimeter == null || perimeter.Entities.Count == 0)
|
||||
return null;
|
||||
|
||||
perimeter.Offset(part.Location);
|
||||
return perimeter;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OpenNest.Engine/PlateResult.cs
Normal file
18
OpenNest.Engine/PlateResult.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.RapidPlanning;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public class PlateResult
|
||||
{
|
||||
public List<ProcessedPart> Parts { get; init; }
|
||||
}
|
||||
|
||||
public readonly struct ProcessedPart
|
||||
{
|
||||
public Part Part { get; init; }
|
||||
public Program ProcessedProgram { get; init; }
|
||||
public RapidPath RapidPath { get; init; }
|
||||
}
|
||||
}
|
||||
44
OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs
Normal file
44
OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.RapidPlanning
|
||||
{
|
||||
public class DirectRapidPlanner : IRapidPlanner
|
||||
{
|
||||
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
|
||||
{
|
||||
var travelLine = new Line(from, to);
|
||||
|
||||
foreach (var cutArea in cutAreas)
|
||||
{
|
||||
if (TravelLineIntersectsShape(travelLine, cutArea))
|
||||
{
|
||||
return new RapidPath
|
||||
{
|
||||
HeadUp = true,
|
||||
Waypoints = new List<Vector>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new RapidPath
|
||||
{
|
||||
HeadUp = false,
|
||||
Waypoints = new List<Vector>()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TravelLineIntersectsShape(Line travelLine, Shape shape)
|
||||
{
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line edge)
|
||||
{
|
||||
if (travelLine.Intersects(edge, out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
OpenNest.Engine/RapidPlanning/IRapidPlanner.cs
Normal file
10
OpenNest.Engine/RapidPlanning/IRapidPlanner.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.RapidPlanning
|
||||
{
|
||||
public interface IRapidPlanner
|
||||
{
|
||||
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
|
||||
}
|
||||
}
|
||||
11
OpenNest.Engine/RapidPlanning/RapidPath.cs
Normal file
11
OpenNest.Engine/RapidPlanning/RapidPath.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.RapidPlanning
|
||||
{
|
||||
public readonly struct RapidPath
|
||||
{
|
||||
public bool HeadUp { get; init; }
|
||||
public List<Vector> Waypoints { get; init; }
|
||||
}
|
||||
}
|
||||
17
OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs
Normal file
17
OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.RapidPlanning
|
||||
{
|
||||
public class SafeHeightRapidPlanner : IRapidPlanner
|
||||
{
|
||||
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
|
||||
{
|
||||
return new RapidPath
|
||||
{
|
||||
HeadUp = true,
|
||||
Waypoints = new List<Vector>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace OpenNest
|
||||
var entities = ConvertProgram.ToGeometry(item.Drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
|
||||
if (shapes.Count == 0)
|
||||
return 0;
|
||||
@@ -65,7 +65,7 @@ namespace OpenNest
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
@@ -80,6 +80,11 @@ namespace OpenNest
|
||||
return new List<double> { 0 };
|
||||
|
||||
var hull = ConvexHull.Compute(points);
|
||||
return GetHullEdgeAngles(hull);
|
||||
}
|
||||
|
||||
public static List<double> GetHullEdgeAngles(Polygon hull)
|
||||
{
|
||||
var vertices = hull.Vertices;
|
||||
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
|
||||
|
||||
|
||||
96
OpenNest.Engine/Sequencing/AdvancedSequencer.cs
Normal file
96
OpenNest.Engine/Sequencing/AdvancedSequencer.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class AdvancedSequencer : IPartSequencer
|
||||
{
|
||||
private readonly SequenceParameters _parameters;
|
||||
|
||||
public AdvancedSequencer(SequenceParameters parameters)
|
||||
{
|
||||
_parameters = parameters;
|
||||
}
|
||||
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
return new List<SequencedPart>();
|
||||
|
||||
var exit = PlateHelper.GetExitPoint(plate);
|
||||
|
||||
// Group parts into rows by Y proximity
|
||||
var rows = GroupIntoRows(parts, _parameters.MinDistanceBetweenRowsColumns);
|
||||
|
||||
// Sort rows bottom-to-top (ascending Y)
|
||||
rows.Sort((a, b) => a.RowY.CompareTo(b.RowY));
|
||||
|
||||
// Determine initial direction based on exit point
|
||||
var leftToRight = exit.X > plate.Size.Width * 0.5;
|
||||
|
||||
var result = new List<SequencedPart>(parts.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var sorted = leftToRight
|
||||
? row.Parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
|
||||
: row.Parts.OrderByDescending(p => p.BoundingBox.Center.X).ToList();
|
||||
|
||||
foreach (var p in sorted)
|
||||
result.Add(new SequencedPart { Part = p });
|
||||
|
||||
if (_parameters.AlternateRowsColumns)
|
||||
leftToRight = !leftToRight;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<PartRow> GroupIntoRows(IReadOnlyList<Part> parts, double minDistance)
|
||||
{
|
||||
// Sort parts by Y center
|
||||
var sorted = parts
|
||||
.OrderBy(p => p.BoundingBox.Center.Y)
|
||||
.ToList();
|
||||
|
||||
var rows = new List<PartRow>();
|
||||
|
||||
foreach (var part in sorted)
|
||||
{
|
||||
var y = part.BoundingBox.Center.Y;
|
||||
var placed = false;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (System.Math.Abs(y - row.RowY) <= minDistance + Tolerance.Epsilon)
|
||||
{
|
||||
row.Parts.Add(part);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed)
|
||||
{
|
||||
var row = new PartRow(y);
|
||||
row.Parts.Add(part);
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private class PartRow
|
||||
{
|
||||
public double RowY { get; }
|
||||
public List<Part> Parts { get; } = new List<Part>();
|
||||
|
||||
public PartRow(double rowY)
|
||||
{
|
||||
RowY = rowY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
OpenNest.Engine/Sequencing/BottomSideSequencer.cs
Normal file
17
OpenNest.Engine/Sequencing/BottomSideSequencer.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class BottomSideSequencer : IPartSequencer
|
||||
{
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
return parts
|
||||
.OrderBy(p => p.Location.Y)
|
||||
.ThenBy(p => p.Location.X)
|
||||
.Select(p => new SequencedPart { Part = p })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
OpenNest.Engine/Sequencing/EdgeStartSequencer.cs
Normal file
36
OpenNest.Engine/Sequencing/EdgeStartSequencer.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class EdgeStartSequencer : IPartSequencer
|
||||
{
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
// Plate(width, length) stores Size with Width/Length swapped internally.
|
||||
// Reconstruct the logical plate box using the BoundingBox origin and the
|
||||
// corrected extents: Size.Length = X-extent, Size.Width = Y-extent.
|
||||
var origin = plate.BoundingBox(false);
|
||||
var plateBox = new OpenNest.Geometry.Box(
|
||||
origin.X, origin.Y,
|
||||
plate.Size.Length,
|
||||
plate.Size.Width);
|
||||
|
||||
return parts
|
||||
.OrderBy(p => MinEdgeDistance(p.BoundingBox.Center, plateBox))
|
||||
.ThenBy(p => p.Location.X)
|
||||
.Select(p => new SequencedPart { Part = p })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static double MinEdgeDistance(OpenNest.Geometry.Vector center, OpenNest.Geometry.Box plateBox)
|
||||
{
|
||||
var distLeft = center.X - plateBox.Left;
|
||||
var distRight = plateBox.Right - center.X;
|
||||
var distBottom = center.Y - plateBox.Bottom;
|
||||
var distTop = plateBox.Top - center.Y;
|
||||
|
||||
return System.Math.Min(System.Math.Min(distLeft, distRight), System.Math.Min(distBottom, distTop));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
OpenNest.Engine/Sequencing/IPartSequencer.cs
Normal file
14
OpenNest.Engine/Sequencing/IPartSequencer.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public readonly struct SequencedPart
|
||||
{
|
||||
public Part Part { get; init; }
|
||||
}
|
||||
|
||||
public interface IPartSequencer
|
||||
{
|
||||
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
|
||||
}
|
||||
}
|
||||
139
OpenNest.Engine/Sequencing/LeastCodeSequencer.cs
Normal file
139
OpenNest.Engine/Sequencing/LeastCodeSequencer.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class LeastCodeSequencer : IPartSequencer
|
||||
{
|
||||
private readonly int _maxIterations;
|
||||
|
||||
public LeastCodeSequencer(int maxIterations = 100)
|
||||
{
|
||||
_maxIterations = maxIterations;
|
||||
}
|
||||
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
return new List<SequencedPart>();
|
||||
|
||||
var exit = PlateHelper.GetExitPoint(plate);
|
||||
var ordered = NearestNeighbor(parts, exit);
|
||||
TwoOpt(ordered, exit);
|
||||
|
||||
var result = new List<SequencedPart>(ordered.Count);
|
||||
foreach (var p in ordered)
|
||||
result.Add(new SequencedPart { Part = p });
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Part> NearestNeighbor(IReadOnlyList<Part> parts, OpenNest.Geometry.Vector exit)
|
||||
{
|
||||
var remaining = new List<Part>(parts);
|
||||
var ordered = new List<Part>(parts.Count);
|
||||
|
||||
var current = exit;
|
||||
while (remaining.Count > 0)
|
||||
{
|
||||
var bestIdx = 0;
|
||||
var bestDist = Distance(current, Center(remaining[0]));
|
||||
|
||||
for (var i = 1; i < remaining.Count; i++)
|
||||
{
|
||||
var d = Distance(current, Center(remaining[i]));
|
||||
if (d < bestDist - Tolerance.Epsilon)
|
||||
{
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
var next = remaining[bestIdx];
|
||||
ordered.Add(next);
|
||||
remaining.RemoveAt(bestIdx);
|
||||
current = Center(next);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private void TwoOpt(List<Part> ordered, OpenNest.Geometry.Vector exit)
|
||||
{
|
||||
var n = ordered.Count;
|
||||
if (n < 3)
|
||||
return;
|
||||
|
||||
for (var iter = 0; iter < _maxIterations; iter++)
|
||||
{
|
||||
var improved = false;
|
||||
|
||||
for (var i = 0; i < n - 1; i++)
|
||||
{
|
||||
for (var j = i + 1; j < n; j++)
|
||||
{
|
||||
var before = RouteDistance(ordered, exit, i, j);
|
||||
Reverse(ordered, i, j);
|
||||
var after = RouteDistance(ordered, exit, i, j);
|
||||
|
||||
if (after < before - Tolerance.Epsilon)
|
||||
{
|
||||
improved = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Revert
|
||||
Reverse(ordered, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!improved)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the total distance of the route starting from exit through all parts.
|
||||
/// Only the segment around the reversed segment [i..j] needs to be checked,
|
||||
/// but here we compute the full route cost for correctness.
|
||||
/// </summary>
|
||||
private static double RouteDistance(List<Part> ordered, OpenNest.Geometry.Vector exit, int i, int j)
|
||||
{
|
||||
// Full route distance: exit -> ordered[0] -> ... -> ordered[n-1]
|
||||
var total = 0.0;
|
||||
var prev = exit;
|
||||
foreach (var p in ordered)
|
||||
{
|
||||
var c = Center(p);
|
||||
total += Distance(prev, c);
|
||||
prev = c;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void Reverse(List<Part> list, int i, int j)
|
||||
{
|
||||
while (i < j)
|
||||
{
|
||||
var tmp = list[i];
|
||||
list[i] = list[j];
|
||||
list[j] = tmp;
|
||||
i++;
|
||||
j--;
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenNest.Geometry.Vector Center(Part part)
|
||||
{
|
||||
return part.BoundingBox.Center;
|
||||
}
|
||||
|
||||
private static double Distance(OpenNest.Geometry.Vector a, OpenNest.Geometry.Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
OpenNest.Engine/Sequencing/LeftSideSequencer.cs
Normal file
17
OpenNest.Engine/Sequencing/LeftSideSequencer.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class LeftSideSequencer : IPartSequencer
|
||||
{
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
return parts
|
||||
.OrderBy(p => p.Location.X)
|
||||
.ThenBy(p => p.Location.Y)
|
||||
.Select(p => new SequencedPart { Part = p })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
OpenNest.Engine/Sequencing/PartSequencerFactory.cs
Normal file
23
OpenNest.Engine/Sequencing/PartSequencerFactory.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public static class PartSequencerFactory
|
||||
{
|
||||
public static IPartSequencer Create(SequenceParameters parameters)
|
||||
{
|
||||
return parameters.Method switch
|
||||
{
|
||||
SequenceMethod.RightSide => new RightSideSequencer(),
|
||||
SequenceMethod.LeftSide => new LeftSideSequencer(),
|
||||
SequenceMethod.BottomSide => new BottomSideSequencer(),
|
||||
SequenceMethod.EdgeStart => new EdgeStartSequencer(),
|
||||
SequenceMethod.LeastCode => new LeastCodeSequencer(),
|
||||
SequenceMethod.Advanced => new AdvancedSequencer(parameters),
|
||||
_ => throw new NotSupportedException(
|
||||
$"Sequence method '{parameters.Method}' is not supported.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
22
OpenNest.Engine/Sequencing/PlateHelper.cs
Normal file
22
OpenNest.Engine/Sequencing/PlateHelper.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
internal static class PlateHelper
|
||||
{
|
||||
public static Vector GetExitPoint(Plate plate)
|
||||
{
|
||||
var w = plate.Size.Width;
|
||||
var l = plate.Size.Length;
|
||||
|
||||
return plate.Quadrant switch
|
||||
{
|
||||
1 => new Vector(w, l),
|
||||
2 => new Vector(0, l),
|
||||
3 => new Vector(0, 0),
|
||||
4 => new Vector(w, 0),
|
||||
_ => new Vector(w, l)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
17
OpenNest.Engine/Sequencing/RightSideSequencer.cs
Normal file
17
OpenNest.Engine/Sequencing/RightSideSequencer.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
public class RightSideSequencer : IPartSequencer
|
||||
{
|
||||
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
|
||||
{
|
||||
return parts
|
||||
.OrderByDescending(p => p.Location.X)
|
||||
.ThenBy(p => p.Location.Y)
|
||||
.Select(p => new SequencedPart { Part = p })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
OpenNest.Engine/StripDirection.cs
Normal file
8
OpenNest.Engine/StripDirection.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum StripDirection
|
||||
{
|
||||
Bottom,
|
||||
Left
|
||||
}
|
||||
}
|
||||
375
OpenNest.Engine/StripNestEngine.cs
Normal file
375
OpenNest.Engine/StripNestEngine.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class StripNestEngine : NestEngineBase
|
||||
{
|
||||
private const int MaxShrinkIterations = 20;
|
||||
|
||||
public StripNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Strip-based nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group-parts fill delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the item that consumes the most plate area (bounding box area x quantity).
|
||||
/// Returns the index into the items list.
|
||||
/// </summary>
|
||||
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
|
||||
{
|
||||
var bestIndex = 0;
|
||||
var bestArea = 0.0;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var bbox = items[i].Drawing.Program.BoundingBox();
|
||||
var qty = items[i].Quantity > 0
|
||||
? items[i].Quantity
|
||||
: (int)(workArea.Area() / bbox.Area());
|
||||
var totalArea = bbox.Area() * qty;
|
||||
|
||||
if (totalArea > bestArea)
|
||||
{
|
||||
bestArea = totalArea;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the strip dimension (height for bottom, width for left) needed
|
||||
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
|
||||
/// This is only an estimate for the shrink loop starting point — the actual fill
|
||||
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
|
||||
/// </summary>
|
||||
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
var qty = item.Quantity > 0
|
||||
? item.Quantity
|
||||
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
|
||||
|
||||
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
|
||||
var perRow0 = (int)(stripLength / bbox.Width);
|
||||
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
|
||||
var dim0 = rows0 * bbox.Length;
|
||||
|
||||
// At 90 deg: rotated bounding box (Width and Length swap)
|
||||
var perRow90 = (int)(stripLength / bbox.Length);
|
||||
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
|
||||
var dim90 = rows90 * bbox.Width;
|
||||
|
||||
var estimate = System.Math.Min(dim0, dim90);
|
||||
|
||||
// Clamp to available dimension
|
||||
return System.Math.Min(estimate, maxDimension);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-drawing strip nesting strategy.
|
||||
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
|
||||
/// in both bottom and left orientations, fills remnants with remaining drawings,
|
||||
/// and returns the denser result.
|
||||
/// </summary>
|
||||
public override List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Select which item gets the strip treatment.
|
||||
var stripIndex = SelectStripItemIndex(items, workArea);
|
||||
var stripItem = items[stripIndex];
|
||||
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
|
||||
|
||||
// Try both orientations.
|
||||
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
|
||||
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
|
||||
|
||||
// Pick the better result.
|
||||
var winner = bottomResult.Score >= leftResult.Score
|
||||
? bottomResult.Parts
|
||||
: leftResult.Parts;
|
||||
|
||||
// Deduct placed quantities from the original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
return winner;
|
||||
}
|
||||
|
||||
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
|
||||
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var result = new StripNestResult { Direction = direction };
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return result;
|
||||
|
||||
// Estimate initial strip dimension.
|
||||
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
|
||||
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
|
||||
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
|
||||
|
||||
// Create the initial strip box.
|
||||
var stripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
|
||||
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
|
||||
|
||||
// Initial fill using DefaultNestEngine (composition, not inheritance).
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var stripParts = inner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
stripBox, progress, token);
|
||||
|
||||
if (stripParts == null || stripParts.Count == 0)
|
||||
return result;
|
||||
|
||||
// Measure actual strip dimension from placed parts.
|
||||
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
|
||||
var actualDim = direction == StripDirection.Bottom
|
||||
? placedBox.Top - workArea.Y
|
||||
: placedBox.Right - workArea.X;
|
||||
|
||||
var bestParts = stripParts;
|
||||
var bestDim = actualDim;
|
||||
var targetCount = stripParts.Count;
|
||||
|
||||
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
|
||||
for (var i = 0; i < MaxShrinkIterations; i++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var trialDim = bestDim - Plate.PartSpacing;
|
||||
if (trialDim <= 0)
|
||||
break;
|
||||
|
||||
var trialBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
|
||||
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
|
||||
|
||||
var trialInner = new DefaultNestEngine(Plate);
|
||||
var trialParts = trialInner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
trialBox, progress, token);
|
||||
|
||||
if (trialParts == null || trialParts.Count < targetCount)
|
||||
break;
|
||||
|
||||
// Same count in a tighter strip — keep going.
|
||||
bestParts = trialParts;
|
||||
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
|
||||
bestDim = direction == StripDirection.Bottom
|
||||
? trialPlacedBox.Top - workArea.Y
|
||||
: trialPlacedBox.Right - workArea.X;
|
||||
}
|
||||
|
||||
// Build remnant box with spacing gap.
|
||||
var spacing = Plate.PartSpacing;
|
||||
var remnantBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y + bestDim + spacing,
|
||||
workArea.Width, workArea.Length - bestDim - spacing)
|
||||
: new Box(workArea.X + bestDim + spacing, workArea.Y,
|
||||
workArea.Width - bestDim - spacing, workArea.Length);
|
||||
|
||||
// Collect all parts.
|
||||
var allParts = new List<Part>(bestParts);
|
||||
|
||||
// If strip item was only partially placed, add leftovers to remainder.
|
||||
var placed = bestParts.Count;
|
||||
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
|
||||
var effectiveRemainder = new List<NestItem>(remainderItems);
|
||||
|
||||
if (leftover > 0)
|
||||
{
|
||||
effectiveRemainder.Add(new NestItem
|
||||
{
|
||||
Drawing = stripItem.Drawing,
|
||||
Quantity = leftover
|
||||
});
|
||||
}
|
||||
|
||||
// Sort remainder by descending bounding box area x quantity.
|
||||
effectiveRemainder = effectiveRemainder
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Fill remnant with remainder items using free-rectangle tracking.
|
||||
// After each fill, the consumed box is split into two non-overlapping
|
||||
// sub-rectangles (guillotine cut) so no usable area is lost.
|
||||
if (remnantBox.Width > 0 && remnantBox.Length > 0)
|
||||
{
|
||||
var freeBoxes = new List<Box> { remnantBox };
|
||||
var remnantProgress = progress != null
|
||||
? new AccumulatingProgress(progress, allParts)
|
||||
: null;
|
||||
|
||||
foreach (var item in effectiveRemainder)
|
||||
{
|
||||
if (token.IsCancellationRequested || freeBoxes.Count == 0)
|
||||
break;
|
||||
|
||||
var itemBbox = item.Drawing.Program.BoundingBox();
|
||||
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
|
||||
|
||||
// Try free boxes from largest to smallest.
|
||||
freeBoxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
|
||||
for (var i = 0; i < freeBoxes.Count; i++)
|
||||
{
|
||||
var box = freeBoxes[i];
|
||||
|
||||
if (System.Math.Min(box.Width, box.Length) < minItemDim)
|
||||
continue;
|
||||
|
||||
var remnantInner = new DefaultNestEngine(Plate);
|
||||
var remnantParts = remnantInner.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
box, remnantProgress, token);
|
||||
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(remnantParts);
|
||||
freeBoxes.RemoveAt(i);
|
||||
|
||||
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
|
||||
SplitFreeBox(box, usedBox, spacing, freeBoxes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Parts = allParts;
|
||||
result.StripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
|
||||
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
|
||||
result.RemnantBox = remnantBox;
|
||||
result.Score = FillScore.Compute(allParts, workArea);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void SplitFreeBox(Box parent, Box used, double spacing, List<Box> freeBoxes)
|
||||
{
|
||||
var hWidth = parent.Right - used.Right - spacing;
|
||||
var vHeight = parent.Top - used.Top - spacing;
|
||||
|
||||
if (hWidth > spacing && vHeight > spacing)
|
||||
{
|
||||
// Guillotine split: give the overlapping corner to the larger strip.
|
||||
var hFullArea = hWidth * parent.Length;
|
||||
var vFullArea = parent.Width * vHeight;
|
||||
|
||||
if (hFullArea >= vFullArea)
|
||||
{
|
||||
// hStrip gets full height; vStrip truncated to left of split line.
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
|
||||
var vWidth = used.Right + spacing - parent.X;
|
||||
if (vWidth > spacing)
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, vWidth, vHeight));
|
||||
}
|
||||
else
|
||||
{
|
||||
// vStrip gets full width; hStrip truncated below split line.
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
|
||||
var hHeight = used.Top + spacing - parent.Y;
|
||||
if (hHeight > spacing)
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, hHeight));
|
||||
}
|
||||
}
|
||||
else if (hWidth > spacing)
|
||||
{
|
||||
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
|
||||
}
|
||||
else if (vHeight > spacing)
|
||||
{
|
||||
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an IProgress to prepend previously placed parts to each report,
|
||||
/// so the UI shows the full picture (strip + remnant) during remnant fills.
|
||||
/// </summary>
|
||||
private class AccumulatingProgress : IProgress<NestProgress>
|
||||
{
|
||||
private readonly IProgress<NestProgress> inner;
|
||||
private readonly List<Part> previousParts;
|
||||
|
||||
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts)
|
||||
{
|
||||
this.inner = inner;
|
||||
this.previousParts = previousParts;
|
||||
}
|
||||
|
||||
public void Report(NestProgress value)
|
||||
{
|
||||
if (value.BestParts != null && previousParts.Count > 0)
|
||||
{
|
||||
var combined = new List<Part>(previousParts.Count + value.BestParts.Count);
|
||||
combined.AddRange(previousParts);
|
||||
combined.AddRange(value.BestParts);
|
||||
value.BestParts = combined;
|
||||
value.BestPartCount = combined.Count;
|
||||
}
|
||||
|
||||
inner.Report(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
OpenNest.Engine/StripNestResult.cs
Normal file
14
OpenNest.Engine/StripNestResult.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal class StripNestResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public Box StripBox { get; set; }
|
||||
public Box RemnantBox { get; set; }
|
||||
public FillScore Score { get; set; }
|
||||
public StripDirection Direction { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user