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:
2026-03-16 11:04:42 -04:00
130 changed files with 16147 additions and 4197 deletions

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

View File

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

View File

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

View File

@@ -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
{

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

View File

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

View File

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

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

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

View File

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

View File

@@ -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

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

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

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

View File

File diff suppressed because it is too large Load Diff

View 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();
}
}
}
}

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

View 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}");
}
}
}
}
}

View File

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

View File

@@ -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>

View File

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

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

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

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

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

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

View 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>()
};
}
}
}

View File

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

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

View 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();
}
}
}

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

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

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

View 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();
}
}
}

View 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.")
};
}
}
}

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

View 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();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum StripDirection
{
Bottom,
Left
}
}

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

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