feat(engine): Opus55 frontier-advance NFP nesting engine
Rename the OpenNest.Engine.Sonnet5 scaffold to OpenNest.Engine.Opus55 and implement an independent whole-job INestingEngine (no built-in engine, registry, or best-fit internals are called or copied). - PartCatalog: snapshot perimeter -> polygon per allowed orientation, with adaptive chord tolerance and MBR-aligned rotations for Automatic parts. - NoFitCache: spacing footprints and cached Clipper2 Minkowski NFPs (convex fast path; concave sweep plus both containment terms). - FrontierPacker: per-(type, orientation) free regions (inner-fit rectangle minus NFPs), updated incrementally; gap-fill-largest, else least front advance per area^beta. - Engine: look-ahead stock choice by estimated whole-job net area, six deterministic variants, tail re-plan of the last 1-3 sheets. - Tests judged by OpenNest.Benchmark's NestValidator, including an NFP containment regression guard. P260805-10.nest (219 parts), all 9 stock sizes: 219/219 valid, 27 sheets, 91.7% utilization, ~7 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
|||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55;
|
||||||
|
|
||||||
|
/// <summary>Direction the packing front sweeps across the sheet (the free strip is left behind it).</summary>
|
||||||
|
internal enum PackAxis
|
||||||
|
{
|
||||||
|
/// <summary>Front moves in +X; parts settle toward low X, then low Y.</summary>
|
||||||
|
X,
|
||||||
|
|
||||||
|
/// <summary>Front moves in +Y; parts settle toward low Y, then low X.</summary>
|
||||||
|
Y,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Placed(Orientation Orientation, double X, double Y)
|
||||||
|
{
|
||||||
|
public double Left => X + Orientation.MinX;
|
||||||
|
public double Right => X + Orientation.MaxX;
|
||||||
|
public double Bottom => Y + Orientation.MinY;
|
||||||
|
public double Top => Y + Orientation.MaxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Parts, double PartArea);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions.
|
||||||
|
///
|
||||||
|
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
|
||||||
|
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
|
||||||
|
/// everything already placed. Each placement subtracts one translated NFP from each region,
|
||||||
|
/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
|
||||||
|
///
|
||||||
|
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
|
||||||
|
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
|
||||||
|
/// largest such part at its lowest such point.
|
||||||
|
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
|
||||||
|
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
|
||||||
|
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FrontierPacker
|
||||||
|
{
|
||||||
|
/// <summary>Slack added around the inner-fit rectangle so zero-width fits survive Clipper;
|
||||||
|
/// chosen points are clamped back, which moves them far less than the clearance margin.</summary>
|
||||||
|
private const double FitSlack = 2e-4;
|
||||||
|
|
||||||
|
private const double Tie = 1e-6;
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<PartType> types;
|
||||||
|
private readonly NoFitCache nfps;
|
||||||
|
private readonly NestPlateStock stock;
|
||||||
|
private readonly PackAxis axis;
|
||||||
|
private readonly double beta;
|
||||||
|
private readonly Box work;
|
||||||
|
private readonly WorkCounter counter;
|
||||||
|
|
||||||
|
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
||||||
|
{
|
||||||
|
this.counter = counter;
|
||||||
|
this.types = types;
|
||||||
|
this.nfps = nfps;
|
||||||
|
this.stock = stock;
|
||||||
|
this.axis = axis;
|
||||||
|
this.beta = beta;
|
||||||
|
work = WorkArea(stock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Box WorkArea(NestPlateStock stock)
|
||||||
|
{
|
||||||
|
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||||
|
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||||
|
return new Box(
|
||||||
|
left + stock.EdgeSpacing.Left,
|
||||||
|
bottom + stock.EdgeSpacing.Bottom,
|
||||||
|
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
|
||||||
|
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the orientation's bounds fit the work area at all (Box.Length is the X extent).</summary>
|
||||||
|
public static bool Fits(Orientation o, Box work) =>
|
||||||
|
o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9;
|
||||||
|
|
||||||
|
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||||
|
{
|
||||||
|
var left = remaining.ToArray();
|
||||||
|
var states = new List<Region>();
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
if (left[type.Index] <= 0)
|
||||||
|
continue;
|
||||||
|
foreach (var o in type.Orientations)
|
||||||
|
if (Fits(o, work))
|
||||||
|
states.Add(new Region(o, work));
|
||||||
|
}
|
||||||
|
|
||||||
|
var placed = new List<Placed>();
|
||||||
|
var partArea = 0.0;
|
||||||
|
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
||||||
|
|
||||||
|
while (states.Count > 0)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var choice = Choose(states, front);
|
||||||
|
if (choice == null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var (region, point) = choice.Value;
|
||||||
|
var part = new Placed(region.Orientation, point.x, point.y);
|
||||||
|
placed.Add(part);
|
||||||
|
var typeIndex = region.Orientation.TypeIndex;
|
||||||
|
partArea += types[typeIndex].Area;
|
||||||
|
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
|
||||||
|
|
||||||
|
if (--left[typeIndex] == 0)
|
||||||
|
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
|
||||||
|
|
||||||
|
// Each surviving region loses the positions the new part now blocks. Regions are
|
||||||
|
// independent, so they update in parallel without affecting determinism.
|
||||||
|
var snapshot = states.ToArray();
|
||||||
|
counter.Add(snapshot.Length);
|
||||||
|
Parallel.For(
|
||||||
|
0,
|
||||||
|
snapshot.Length,
|
||||||
|
new ParallelOptions { CancellationToken = token },
|
||||||
|
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
||||||
|
);
|
||||||
|
states.RemoveAll(s => s.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SheetFill(stock, placed, partArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (Region, PointD)? Choose(List<Region> states, double front)
|
||||||
|
{
|
||||||
|
Region? bestRegion = null;
|
||||||
|
var bestPoint = default(PointD);
|
||||||
|
var bestFills = false;
|
||||||
|
var bestValue = double.PositiveInfinity;
|
||||||
|
var bestSide = double.PositiveInfinity;
|
||||||
|
var bestLead = double.PositiveInfinity;
|
||||||
|
|
||||||
|
foreach (var region in states)
|
||||||
|
{
|
||||||
|
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||||
|
continue;
|
||||||
|
var area = types[region.Orientation.TypeIndex].Area;
|
||||||
|
var fills = advance <= Tie;
|
||||||
|
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||||
|
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||||
|
|
||||||
|
var better = bestRegion == null
|
||||||
|
|| (fills && !bestFills)
|
||||||
|
|| (
|
||||||
|
fills == bestFills
|
||||||
|
&& (
|
||||||
|
value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||||
|
|| (
|
||||||
|
value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||||
|
&& (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!better)
|
||||||
|
continue;
|
||||||
|
bestRegion = region;
|
||||||
|
bestPoint = point;
|
||||||
|
bestFills = fills;
|
||||||
|
bestValue = value;
|
||||||
|
bestSide = side;
|
||||||
|
bestLead = lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestRegion == null ? null : (bestRegion, bestPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||||
|
private sealed class Region
|
||||||
|
{
|
||||||
|
private readonly double minX, minY, maxX, maxY;
|
||||||
|
private PathsD free;
|
||||||
|
private RectD bounds;
|
||||||
|
|
||||||
|
public Region(Orientation orientation, Box work)
|
||||||
|
{
|
||||||
|
Orientation = orientation;
|
||||||
|
minX = work.Left - orientation.MinX;
|
||||||
|
maxX = work.Right - orientation.MaxX;
|
||||||
|
minY = work.Bottom - orientation.MinY;
|
||||||
|
maxY = work.Top - orientation.MaxY;
|
||||||
|
// Guard against fits that are infeasible by less than the bounds tolerance.
|
||||||
|
if (maxX < minX)
|
||||||
|
maxX = minX;
|
||||||
|
if (maxY < minY)
|
||||||
|
maxY = minY;
|
||||||
|
free = new PathsD
|
||||||
|
{
|
||||||
|
new PathD
|
||||||
|
{
|
||||||
|
new(minX - FitSlack, minY - FitSlack),
|
||||||
|
new(maxX + FitSlack, minY - FitSlack),
|
||||||
|
new(maxX + FitSlack, maxY + FitSlack),
|
||||||
|
new(minX - FitSlack, maxY + FitSlack),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
bounds = Clipper.GetBounds(free);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Orientation Orientation { get; }
|
||||||
|
public bool IsEmpty => free.Count == 0;
|
||||||
|
|
||||||
|
public void Subtract(Nfp nfp, double dx, double dy)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
nfp.Bounds.right + dx < bounds.left
|
||||||
|
|| nfp.Bounds.left + dx > bounds.right
|
||||||
|
|| nfp.Bounds.bottom + dy < bounds.top
|
||||||
|
|| nfp.Bounds.top + dy > bounds.bottom
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
var clip = Clipper.TranslatePaths(nfp.Region, dx, dy);
|
||||||
|
free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision);
|
||||||
|
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
|
||||||
|
free.RemoveAll(p => p.Count < 3);
|
||||||
|
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
|
||||||
|
/// then lowest leading edge. Vertices suffice because every score is linear in position.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
|
||||||
|
{
|
||||||
|
point = default;
|
||||||
|
advance = side = lead = double.PositiveInfinity;
|
||||||
|
var found = false;
|
||||||
|
var o = Orientation;
|
||||||
|
foreach (var path in free)
|
||||||
|
foreach (var raw in path)
|
||||||
|
{
|
||||||
|
var x = System.Math.Clamp(raw.x, minX, maxX);
|
||||||
|
var y = System.Math.Clamp(raw.y, minY, maxY);
|
||||||
|
double reach, across, start;
|
||||||
|
if (axis == PackAxis.X)
|
||||||
|
{
|
||||||
|
reach = x + o.MaxX;
|
||||||
|
across = y + o.MinY;
|
||||||
|
start = x + o.MinX;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reach = y + o.MaxY;
|
||||||
|
across = x + o.MinX;
|
||||||
|
start = y + o.MinY;
|
||||||
|
}
|
||||||
|
var adv = System.Math.Max(0, reach - front);
|
||||||
|
var better = !found
|
||||||
|
|| adv < advance - Tie
|
||||||
|
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
|
||||||
|
if (!better)
|
||||||
|
continue;
|
||||||
|
found = true;
|
||||||
|
point = new PointD(x, y);
|
||||||
|
advance = adv;
|
||||||
|
side = across;
|
||||||
|
lead = start;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Clipper2Lib;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value.
|
||||||
|
///
|
||||||
|
/// Every placed part owns a footprint: its outline grown by half the required clearance
|
||||||
|
/// (plus its own chord tolerance). Two parts respect the clearance exactly when their
|
||||||
|
/// footprints do not overlap, so the whole spacing rule reduces to NFP containment.
|
||||||
|
/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once
|
||||||
|
/// per job and reused by every sheet, stock trial and strategy variant.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NoFitCache
|
||||||
|
{
|
||||||
|
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
|
||||||
|
public const int Precision = 4;
|
||||||
|
|
||||||
|
private readonly double halfClearance;
|
||||||
|
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
|
||||||
|
private readonly ConcurrentDictionary<(int, int, int, int), Lazy<Nfp>> nfps = new();
|
||||||
|
|
||||||
|
public NoFitCache(double clearance)
|
||||||
|
{
|
||||||
|
halfClearance = clearance / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PathD Footprint(Orientation o) =>
|
||||||
|
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
|
||||||
|
|
||||||
|
/// <summary>NFP of <paramref name="moving"/> around <paramref name="fixedPart"/> placed at the origin.</summary>
|
||||||
|
public Nfp Get(Orientation fixedPart, Orientation moving) =>
|
||||||
|
nfps.GetOrAdd(
|
||||||
|
(fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index),
|
||||||
|
_ => new Lazy<Nfp>(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication)
|
||||||
|
)
|
||||||
|
.Value;
|
||||||
|
|
||||||
|
private PathD BuildFootprint(Orientation o)
|
||||||
|
{
|
||||||
|
// Miter joins (squared past the limit) always contain the exact round offset, so the
|
||||||
|
// footprint is a superset of "every point within the clearance of the outline".
|
||||||
|
var inflated = Clipper.InflatePaths(
|
||||||
|
new PathsD { o.Outline },
|
||||||
|
halfClearance + o.Tolerance,
|
||||||
|
JoinType.Miter,
|
||||||
|
EndType.Polygon,
|
||||||
|
2.0,
|
||||||
|
Precision,
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First();
|
||||||
|
if (!Clipper.IsPositive(best))
|
||||||
|
best.Reverse();
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Nfp Build(Orientation fixedPart, Orientation moving)
|
||||||
|
{
|
||||||
|
var a = Footprint(fixedPart);
|
||||||
|
var b = Footprint(moving);
|
||||||
|
var negB = new PathD(b.Count);
|
||||||
|
foreach (var p in b)
|
||||||
|
negB.Add(new PointD(-p.x, -p.y));
|
||||||
|
|
||||||
|
PathsD region;
|
||||||
|
if (IsConvex(a) && IsConvex(b))
|
||||||
|
{
|
||||||
|
region = new PathsD { ConvexSum(a, negB) };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
|
||||||
|
// copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
|
||||||
|
// (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
|
||||||
|
var sweep = Minkowski.Sum(negB, a, true, Precision);
|
||||||
|
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
|
||||||
|
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
|
||||||
|
region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
|
||||||
|
}
|
||||||
|
return new Nfp(region, Clipper.GetBounds(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minkowski sum of two convex CCW polygons by merging edges in angle order.</summary>
|
||||||
|
private static PathD ConvexSum(PathD a, PathD b)
|
||||||
|
{
|
||||||
|
var ia = LowestIndex(a);
|
||||||
|
var ib = LowestIndex(b);
|
||||||
|
var result = new PathD(a.Count + b.Count);
|
||||||
|
var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y);
|
||||||
|
int i = 0, j = 0;
|
||||||
|
while (i < a.Count || j < b.Count)
|
||||||
|
{
|
||||||
|
result.Add(current);
|
||||||
|
var ea = i < a.Count ? Edge(a, ia + i) : default;
|
||||||
|
var eb = j < b.Count ? Edge(b, ib + j) : default;
|
||||||
|
// Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi).
|
||||||
|
double order;
|
||||||
|
if (i >= a.Count)
|
||||||
|
order = -1;
|
||||||
|
else if (j >= b.Count)
|
||||||
|
order = 1;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var difference = EdgeAngle(eb) - EdgeAngle(ea);
|
||||||
|
order = System.Math.Abs(difference) < 1e-12 ? 0 : difference;
|
||||||
|
}
|
||||||
|
if (order > 0)
|
||||||
|
{
|
||||||
|
current = new PointD(current.x + ea.x, current.y + ea.y);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else if (order < 0)
|
||||||
|
{
|
||||||
|
current = new PointD(current.x + eb.x, current.y + eb.y);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y);
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double EdgeAngle(PointD edge)
|
||||||
|
{
|
||||||
|
var angle = System.Math.Atan2(edge.y, edge.x);
|
||||||
|
return angle < 0 ? angle + System.Math.PI * 2 : angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PointD Edge(PathD path, int index)
|
||||||
|
{
|
||||||
|
var from = path[index % path.Count];
|
||||||
|
var to = path[(index + 1) % path.Count];
|
||||||
|
return new PointD(to.x - from.x, to.y - from.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle.</summary>
|
||||||
|
private static int LowestIndex(PathD path)
|
||||||
|
{
|
||||||
|
var best = 0;
|
||||||
|
for (var i = 1; i < path.Count; i++)
|
||||||
|
if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x))
|
||||||
|
best = i;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsConvex(PathD path)
|
||||||
|
{
|
||||||
|
var n = path.Count;
|
||||||
|
if (n < 3)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var a = path[i];
|
||||||
|
var b = path[(i + 1) % n];
|
||||||
|
var c = path[(i + 2) % n];
|
||||||
|
var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
|
||||||
|
if (cross < -1e-12)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds.</summary>
|
||||||
|
internal sealed record Nfp(PathsD Region, RectD Bounds);
|
||||||
+3
-2
@@ -1,13 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>OpenNest.Engine.Sonnet5</RootNamespace>
|
<RootNamespace>OpenNest.Engine.Opus55</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Engine.Sonnet5</AssemblyName>
|
<AssemblyName>OpenNest.Engine.Opus55</AssemblyName>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="tests/**/*.cs" />
|
<Compile Remove="tests/**/*.cs" />
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Engine.Opus55.Tests" />
|
||||||
<ProjectReference Include="../OpenNest.Engine/OpenNest.Engine.csproj" />
|
<ProjectReference Include="../OpenNest.Engine/OpenNest.Engine.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frontier-advance NFP packer with look-ahead stock selection.
|
||||||
|
///
|
||||||
|
/// Per sheet, <see cref="FrontierPacker"/> keeps the exact free region of every
|
||||||
|
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
|
||||||
|
/// either the largest part that fills a gap behind the packing front, or the part that advances
|
||||||
|
/// the front least per unit of area covered. Across sheets, every available stock size is
|
||||||
|
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
|
||||||
|
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
|
||||||
|
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
|
||||||
|
///
|
||||||
|
/// Fully deterministic: no clocks or randomness influence any decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Opus55NestingEngine : INestingEngine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extra clearance beyond the stock's part spacing, in job units. Validators polygonize arcs
|
||||||
|
/// circumscribed at 0.01 per side, so two tangent true arcs can read as up to 0.02 closer
|
||||||
|
/// than they are; the rest absorbs Clipper's 1e-4 grid and inner-fit clamping.
|
||||||
|
/// </summary>
|
||||||
|
internal const double ClearanceMargin = 0.022;
|
||||||
|
|
||||||
|
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||||
|
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||||
|
{
|
||||||
|
(PackAxis.X, 1.0),
|
||||||
|
(PackAxis.Y, 1.0),
|
||||||
|
(PackAxis.X, 0.5),
|
||||||
|
(PackAxis.Y, 0.5),
|
||||||
|
(PackAxis.X, 1.5),
|
||||||
|
(PackAxis.Y, 1.5),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic work budget, in free-region subtractions, after which no further variant
|
||||||
|
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
|
||||||
|
/// </summary>
|
||||||
|
internal long WorkBudget { get; init; } = 1_500_000;
|
||||||
|
|
||||||
|
public NestJobResult Solve(
|
||||||
|
NestJob job,
|
||||||
|
IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(job);
|
||||||
|
var types = PartCatalog.Build(job);
|
||||||
|
var solver = new Solver(job, types, progress, token);
|
||||||
|
|
||||||
|
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
||||||
|
var demand = new int[types.Count];
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
var placeable = job.Plates.Any(stock =>
|
||||||
|
stock.Quantity != 0
|
||||||
|
&& type.Orientations.Any(o => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock)))
|
||||||
|
);
|
||||||
|
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plan? best = null;
|
||||||
|
foreach (var (axis, beta) in Variants)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (best != null && solver.Work.Value >= WorkBudget)
|
||||||
|
break;
|
||||||
|
var plan = solver.Plan(demand, axis, beta);
|
||||||
|
if (best == null || plan.IsBetterThan(best))
|
||||||
|
best = plan;
|
||||||
|
if (best.Unplaced == 0 && best.Sheets.Count == 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
|
||||||
|
best = solver.ImproveTail(best!, WorkBudget * 2);
|
||||||
|
return BuildResult(job, types, best, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
|
||||||
|
private sealed class Solver(
|
||||||
|
NestJob job,
|
||||||
|
IReadOnlyList<PartType> types,
|
||||||
|
IProgress<NestJobProgress>? progress,
|
||||||
|
CancellationToken token
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private const int MaxTail = 3;
|
||||||
|
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||||
|
|
||||||
|
public WorkCounter Work { get; } = new();
|
||||||
|
|
||||||
|
private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea);
|
||||||
|
|
||||||
|
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
||||||
|
{
|
||||||
|
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null);
|
||||||
|
var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count);
|
||||||
|
var reason = run.Reason;
|
||||||
|
if (unplaced > 0 && reason == NestJobStopReason.Completed)
|
||||||
|
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
|
||||||
|
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with
|
||||||
|
/// every stock forced as the first sheet, under every variant; the cheapest complete
|
||||||
|
/// re-plan that beats the current tail replaces it. Tails are small and effort is metered.
|
||||||
|
/// </summary>
|
||||||
|
public Plan ImproveTail(Plan plan, long budget)
|
||||||
|
{
|
||||||
|
var sheets = plan.Sheets.ToList();
|
||||||
|
for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++)
|
||||||
|
{
|
||||||
|
if (Work.Value >= budget)
|
||||||
|
break;
|
||||||
|
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||||
|
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||||
|
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||||
|
var tailNet = tail.Sum(s => SheetEconomics.NetArea(job.Options, s));
|
||||||
|
var tailDemand = new int[types.Count];
|
||||||
|
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||||
|
tailDemand[part.Orientation.TypeIndex]++;
|
||||||
|
var used = prefix
|
||||||
|
.GroupBy(s => s.Stock.Id)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||||
|
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
|
||||||
|
|
||||||
|
Run? bestRun = null;
|
||||||
|
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
|
||||||
|
foreach (var (axis, beta) in Variants)
|
||||||
|
foreach (var first in job.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var run = Decode(tailDemand, axis, beta, used, cap, first);
|
||||||
|
if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet)
|
||||||
|
continue;
|
||||||
|
bestRun = run;
|
||||||
|
bestNet = run.Net;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestRun == null)
|
||||||
|
continue;
|
||||||
|
sheets = prefix.Concat(bestRun.Sheets).ToList();
|
||||||
|
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NoFitCache CacheFor(NestPlateStock stock)
|
||||||
|
{
|
||||||
|
var clearance = System.Math.Max(0, stock.PartSpacing) + ClearanceMargin;
|
||||||
|
if (!caches.TryGetValue(clearance, out var cache))
|
||||||
|
caches[clearance] = cache = new NoFitCache(clearance);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
||||||
|
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
||||||
|
/// <paramref name="first"/>, when set, forces the stock of the first sheet.
|
||||||
|
/// </summary>
|
||||||
|
private Run Decode(
|
||||||
|
int[] demand,
|
||||||
|
PackAxis axis,
|
||||||
|
double beta,
|
||||||
|
IReadOnlyDictionary<string, int> usedBefore,
|
||||||
|
int? sheetCap,
|
||||||
|
NestPlateStock? first
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var remaining = (int[])demand.Clone();
|
||||||
|
var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal);
|
||||||
|
var sheets = new List<SheetFill>();
|
||||||
|
var net = 0.0;
|
||||||
|
NestJobStopReason reason;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (remaining.All(r => r == 0))
|
||||||
|
{
|
||||||
|
reason = NestJobStopReason.Completed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (sheetCap is int cap && sheets.Count >= cap)
|
||||||
|
{
|
||||||
|
reason = NestJobStopReason.PlateLimitReached;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trials = new List<(SheetFill Fill, double Net)>();
|
||||||
|
foreach (var stock in job.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first))
|
||||||
|
continue;
|
||||||
|
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||||
|
continue;
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
||||||
|
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||||
|
var fill = packer.Fill(remaining, token);
|
||||||
|
if (fill.Parts.Count > 0)
|
||||||
|
trials.Add((fill, SheetEconomics.NetArea(job.Options, fill)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trials.Count == 0)
|
||||||
|
{
|
||||||
|
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||||
|
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial
|
||||||
|
// achieved, so a sheet that finishes the job competes fairly with a denser partial one.
|
||||||
|
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
|
||||||
|
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
|
||||||
|
var chosen = trials
|
||||||
|
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
|
||||||
|
.OrderBy(t => t.Estimate)
|
||||||
|
.ThenByDescending(t => t.Fill.Parts.Count)
|
||||||
|
.ThenBy(t => t.order)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
sheets.Add(chosen.Fill);
|
||||||
|
net += chosen.Net;
|
||||||
|
used[chosen.Fill.Stock.Id]++;
|
||||||
|
foreach (var part in chosen.Fill.Parts)
|
||||||
|
remaining[part.Orientation.TypeIndex]--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Run(sheets, net, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
||||||
|
|
||||||
|
private static NestJobResult BuildResult(
|
||||||
|
NestJob job,
|
||||||
|
IReadOnlyList<PartType> types,
|
||||||
|
Plan plan,
|
||||||
|
IProgress<NestJobProgress>? progress
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var placed = new int[types.Count];
|
||||||
|
var plates = new List<NestJobPlateResult>(plan.Sheets.Count);
|
||||||
|
var committedParts = 0;
|
||||||
|
foreach (var sheet in plan.Sheets)
|
||||||
|
{
|
||||||
|
var placements = sheet.Parts.Select(p =>
|
||||||
|
{
|
||||||
|
var type = types[p.Orientation.TypeIndex];
|
||||||
|
return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation);
|
||||||
|
});
|
||||||
|
plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList()));
|
||||||
|
committedParts += sheet.Parts.Count;
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts));
|
||||||
|
}
|
||||||
|
|
||||||
|
var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index]));
|
||||||
|
var usage = job.Plates.Select(stock =>
|
||||||
|
{
|
||||||
|
var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock));
|
||||||
|
return new StockUsage(stock.Id, count, stock.Quantity - count);
|
||||||
|
});
|
||||||
|
var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete;
|
||||||
|
return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||||
|
{
|
||||||
|
public bool IsBetterThan(Plan other)
|
||||||
|
{
|
||||||
|
if (Unplaced != other.Unplaced)
|
||||||
|
return Unplaced < other.Unplaced;
|
||||||
|
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
|
||||||
|
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||||
|
return Cost < other.Cost;
|
||||||
|
return Sheets.Count < other.Sheets.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deterministic effort meter shared by all packers in one solve.</summary>
|
||||||
|
internal sealed class WorkCounter
|
||||||
|
{
|
||||||
|
private long value;
|
||||||
|
public long Value => Interlocked.Read(ref value);
|
||||||
|
public void Add(long amount) => Interlocked.Add(ref value, amount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation
|
||||||
|
/// (reference point = snapshot origin), and the outline's conservative bounds.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Orientation
|
||||||
|
{
|
||||||
|
public required int TypeIndex { get; init; }
|
||||||
|
public required int Index { get; init; }
|
||||||
|
public required double Rotation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>CCW outline whose every point lies within <see cref="Tolerance"/> of the true perimeter.</summary>
|
||||||
|
public required PathD Outline { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
||||||
|
public required double Tolerance { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
|
||||||
|
public required double MinX { get; init; }
|
||||||
|
public required double MinY { get; init; }
|
||||||
|
public required double MaxX { get; init; }
|
||||||
|
public required double MaxY { get; init; }
|
||||||
|
|
||||||
|
public double Width => MaxX - MinX;
|
||||||
|
public double Height => MaxY - MinY;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PartType
|
||||||
|
{
|
||||||
|
public required int Index { get; init; }
|
||||||
|
public required NestJobPart Part { get; init; }
|
||||||
|
public required double Area { get; init; }
|
||||||
|
public required IReadOnlyList<Orientation> Orientations { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry
|
||||||
|
/// cannot be read are kept with no orientations, so they surface as unplaced instead of
|
||||||
|
/// failing the whole job.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PartCatalog
|
||||||
|
{
|
||||||
|
/// <summary>Finest chord deviation of the working outline from true arcs, in job units.</summary>
|
||||||
|
public const double ChordTolerance = 0.002;
|
||||||
|
|
||||||
|
/// <summary>Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m).</summary>
|
||||||
|
private const int TargetVertices = 64;
|
||||||
|
|
||||||
|
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
||||||
|
private const int MaxOrientations = 8;
|
||||||
|
|
||||||
|
private const double TwoPi = System.Math.PI * 2;
|
||||||
|
|
||||||
|
public static IReadOnlyList<PartType> Build(NestJob job)
|
||||||
|
{
|
||||||
|
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
||||||
|
// pair costs a feasible-region update per placement.
|
||||||
|
var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations);
|
||||||
|
var types = new List<PartType>(job.Parts.Count);
|
||||||
|
for (var index = 0; index < job.Parts.Count; index++)
|
||||||
|
{
|
||||||
|
var part = job.Parts[index];
|
||||||
|
Shape? perimeter;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
perimeter = ReadPerimeter(part.Geometry);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
perimeter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (perimeter == null)
|
||||||
|
{
|
||||||
|
types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var angles = CandidateAngles(part.Rotation, perimeter, perType);
|
||||||
|
var tolerance = ChooseTolerance(perimeter);
|
||||||
|
var orientations = new List<Orientation>();
|
||||||
|
var signatures = new List<string>();
|
||||||
|
foreach (var angle in angles)
|
||||||
|
{
|
||||||
|
var outline = Polygonize(perimeter, angle, tolerance);
|
||||||
|
if (outline.Count < 3)
|
||||||
|
continue;
|
||||||
|
// Point-symmetric parts (rectangles, discs...) look identical at several angles;
|
||||||
|
// evaluating duplicates only costs time.
|
||||||
|
var signature = Signature(outline);
|
||||||
|
if (signatures.Contains(signature))
|
||||||
|
continue;
|
||||||
|
signatures.Add(signature);
|
||||||
|
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||||
|
}
|
||||||
|
|
||||||
|
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
|
||||||
|
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram
|
||||||
|
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
|
||||||
|
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
|
||||||
|
.ToList();
|
||||||
|
if (entities.Count == 0)
|
||||||
|
return null;
|
||||||
|
var profile = new ShapeProfile(entities);
|
||||||
|
return profile.Perimeter is { } perimeter && perimeter.Area() > 1e-9 ? perimeter : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
||||||
|
/// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay.
|
||||||
|
/// </summary>
|
||||||
|
private static double ChooseTolerance(Shape perimeter)
|
||||||
|
{
|
||||||
|
var box = perimeter.BoundingBox;
|
||||||
|
var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length));
|
||||||
|
var tolerance = ChordTolerance;
|
||||||
|
while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices)
|
||||||
|
tolerance *= 2;
|
||||||
|
return tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PathD Polygonize(Shape perimeter, double angle, double tolerance)
|
||||||
|
{
|
||||||
|
var shape = (Shape)perimeter.Clone();
|
||||||
|
if (angle != 0)
|
||||||
|
shape.Rotate(angle);
|
||||||
|
var polygon = shape.ToPolygonWithTolerance(tolerance);
|
||||||
|
var path = new PathD(polygon.Vertices.Count);
|
||||||
|
foreach (var v in polygon.Vertices)
|
||||||
|
{
|
||||||
|
if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9)
|
||||||
|
continue;
|
||||||
|
path.Add(new PointD(v.X, v.Y));
|
||||||
|
}
|
||||||
|
if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9)
|
||||||
|
path.RemoveAt(path.Count - 1);
|
||||||
|
if (!Clipper.IsPositive(path))
|
||||||
|
path.Reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
|
||||||
|
{
|
||||||
|
var bounds = Clipper.GetBounds(outline);
|
||||||
|
return new Orientation
|
||||||
|
{
|
||||||
|
TypeIndex = typeIndex,
|
||||||
|
Index = index,
|
||||||
|
Rotation = angle,
|
||||||
|
Outline = outline,
|
||||||
|
Tolerance = tolerance,
|
||||||
|
MinX = bounds.left - tolerance,
|
||||||
|
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
||||||
|
MaxX = bounds.right + tolerance,
|
||||||
|
MaxY = bounds.bottom + tolerance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Signature(PathD outline)
|
||||||
|
{
|
||||||
|
var bounds = Clipper.GetBounds(outline);
|
||||||
|
var points = outline
|
||||||
|
.Select(p => (System.Math.Round(p.x - bounds.left, 5), System.Math.Round(p.y - bounds.top, 5)))
|
||||||
|
.OrderBy(p => p.Item1)
|
||||||
|
.ThenBy(p => p.Item2)
|
||||||
|
.Select(p => $"{p.Item1:R},{p.Item2:R}");
|
||||||
|
return string.Join(";", points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotations to try, all satisfying the part's policy. Automatic parts get the four
|
||||||
|
/// right angles plus the two orientations that align their minimum-area bounding
|
||||||
|
/// rectangle with the sheet axes.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit)
|
||||||
|
{
|
||||||
|
var raw = new List<double>();
|
||||||
|
switch (policy.Kind)
|
||||||
|
{
|
||||||
|
case RotationPolicyKind.Fixed:
|
||||||
|
raw.Add(policy.Start);
|
||||||
|
if (policy.Allow180Equivalent)
|
||||||
|
raw.Add(policy.Start + System.Math.PI);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RotationPolicyKind.BoundedSweep:
|
||||||
|
{
|
||||||
|
var steps = (int)System.Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
|
||||||
|
var samples = System.Math.Min(steps + 1, policy.Allow180Equivalent ? System.Math.Max(1, limit / 2) : limit);
|
||||||
|
for (var i = 0; i < samples; i++)
|
||||||
|
{
|
||||||
|
var k = samples == 1 ? 0 : (int)System.Math.Round(i * (double)steps / (samples - 1));
|
||||||
|
raw.Add(policy.Start + k * policy.Step);
|
||||||
|
if (policy.Allow180Equivalent)
|
||||||
|
raw.Add(policy.Start + k * policy.Step + System.Math.PI);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var rightAngles = new[] { 0, System.Math.PI / 2, System.Math.PI, System.Math.PI * 1.5 };
|
||||||
|
var aligned = AlignedAngle(perimeter);
|
||||||
|
raw.Add(0);
|
||||||
|
raw.Add(System.Math.PI / 2);
|
||||||
|
if (aligned is double a)
|
||||||
|
{
|
||||||
|
raw.Add(Normalize(a));
|
||||||
|
raw.Add(Normalize(a + System.Math.PI / 2));
|
||||||
|
}
|
||||||
|
raw.Add(System.Math.PI);
|
||||||
|
raw.Add(System.Math.PI * 1.5);
|
||||||
|
if (aligned is double b)
|
||||||
|
{
|
||||||
|
raw.Add(Normalize(b + System.Math.PI));
|
||||||
|
raw.Add(Normalize(b + System.Math.PI * 1.5));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<double>();
|
||||||
|
foreach (var angle in raw)
|
||||||
|
{
|
||||||
|
if (!policy.Allows(angle))
|
||||||
|
continue;
|
||||||
|
if (result.Any(existing => SameTurn(existing, angle)))
|
||||||
|
continue;
|
||||||
|
result.Add(angle);
|
||||||
|
if (result.Count >= limit)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? AlignedAngle(Shape perimeter)
|
||||||
|
{
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(ChordTolerance * 5);
|
||||||
|
if (polygon.Vertices.Count < 3)
|
||||||
|
return null;
|
||||||
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(polygon.Vertices);
|
||||||
|
var angle = Normalize(-mbr.Angle) % (System.Math.PI / 2);
|
||||||
|
// Already axis-aligned (within ~0.05°): the right angles cover it.
|
||||||
|
if (angle < 1e-3 || System.Math.PI / 2 - angle < 1e-3)
|
||||||
|
return null;
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Normalize(double angle)
|
||||||
|
{
|
||||||
|
var value = angle % TwoPi;
|
||||||
|
return value < 0 ? value + TwoPi : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameTurn(double a, double b)
|
||||||
|
{
|
||||||
|
var delta = System.Math.Abs(Normalize(a - b));
|
||||||
|
return delta < 1e-9 || TwoPi - delta < 1e-9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# OpenNest.Engine.Opus55
|
||||||
|
|
||||||
|
An independent whole-job `INestingEngine`: **frontier-advance NFP packing with look-ahead
|
||||||
|
stock selection**. It does not call, wrap, or select over any built-in engine
|
||||||
|
(`StockLadderNestingEngine`, `FixedStrategyNestingEngine` strategies, `PlateNesterFactory`,
|
||||||
|
`NestingEngineRegistry`), nor the removed `OpenNest.Engine/Nfp` bottom-left-fill/annealing code.
|
||||||
|
Every placement decision (which part, which rotation, where, on which sheet) comes from the logic below.
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
**1. Geometry (`PartCatalog`, `NoFitCache`)**
|
||||||
|
- Each part's outer perimeter is polygonized with a known chord tolerance (0.002 by default,
|
||||||
|
coarsened for arc-heavy parts until the outline is ≤ ~64 vertices, capped at 0.1% of part size).
|
||||||
|
- Candidate rotations come from the part's `RotationPolicy`: for `Automatic`, the four right
|
||||||
|
angles plus the two orientations that axis-align the minimum-area bounding rectangle
|
||||||
|
(`RotatingCalipers`); for sweeps, up to 8 evenly spaced legal steps. Point-symmetric duplicates are dropped.
|
||||||
|
- Each orientation gets a **footprint**: outline inflated (miter joins, so it contains the exact
|
||||||
|
round offset) by `(spacing + 0.022) / 2 + chordTolerance`. Two parts respect the spacing
|
||||||
|
when their footprints don't overlap. The 0.022 covers validators that polygonize arcs
|
||||||
|
circumscribed at 0.01 per side, plus Clipper's 1e-4 grid.
|
||||||
|
- **No-fit polygons** between footprints come from Clipper2 Minkowski sums: an O(n+m)
|
||||||
|
edge merge for convex pairs, and for concave pairs the boundary sweep ∪ (A + p₀) ∪ (−B + a₀).
|
||||||
|
The last two terms cover "B inside A" and "B swallows A". NFPs are cached per orientation pair.
|
||||||
|
|
||||||
|
**2. Sheet filling (`FrontierPacker`)**
|
||||||
|
- For every (part type, orientation) still in play, the packer keeps the exact **free region** of
|
||||||
|
legal reference points: the inner-fit rectangle minus the NFPs of everything placed. Each
|
||||||
|
placement subtracts one translated NFP from each region (in parallel, which stays deterministic).
|
||||||
|
Regions only shrink, and an empty region is retired for the rest of the sheet.
|
||||||
|
- At every step all remaining types × orientations compete (there is no fixed placement sequence):
|
||||||
|
1. **Gap fill:** if any part fits without pushing the packing front forward, place the
|
||||||
|
*largest* such part at its lowest point.
|
||||||
|
2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the
|
||||||
|
most material coverage for the sheet length it consumes.
|
||||||
|
- The front sweeps along X or Y, which leaves one full-width offcut strip for salvage credit.
|
||||||
|
|
||||||
|
**3. Whole job (`Opus55NestingEngine`, `SheetEconomics`)**
|
||||||
|
- Sheet by sheet, every available stock size is trial-filled. The trial with the lowest
|
||||||
|
*estimated whole-job cost* (its net area, plus the remaining demand priced at the best
|
||||||
|
efficiency any trial achieved) is committed. This lets a sheet that finishes the job beat a
|
||||||
|
denser partial one.
|
||||||
|
- Net area = sheet area − `SalvageRate` × the largest qualifying full-width/full-length edge
|
||||||
|
offcut. This is the objective the benchmark scores.
|
||||||
|
- Six strategy variants (front axis X/Y × β ∈ {1, 0.5, 1.5}) each run whole-job, and the cheapest
|
||||||
|
plan wins (fewest unplaced, then cost, then sheets). A **tail re-plan** then re-decodes the
|
||||||
|
parts on the last 1–3 sheets with each stock forced first, and keeps any strictly cheaper result.
|
||||||
|
- **Deterministic:** no clock or randomness affects decisions. Effort is capped by a
|
||||||
|
count-based work budget (free-region subtractions), not wall time.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Opus55NestingEngine.cs` | `Solve()`: demand filtering, variants, stock look-ahead, tail re-plan, result assembly |
|
||||||
|
| `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule |
|
||||||
|
| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) |
|
||||||
|
| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation |
|
||||||
|
| `SheetEconomics.cs` | Net-area objective with salvage credit |
|
||||||
|
| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` |
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj -c Release
|
||||||
|
dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
This project is intentionally **outside** `OpenNest.sln`, the same pattern as the
|
||||||
|
`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin.
|
||||||
|
|
||||||
|
## Benchmark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||||
|
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||||
|
cp OpenNest.Engine.Opus55/bin/Release/net8.0/OpenNest.Engine.Opus55.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||||
|
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-manifest-or-folder>
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine reports as `Opus55NestingEngine`.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **No part-in-part:** holes are treated as solid, so small parts never nest inside cutouts.
|
||||||
|
- **Clearance padding:** gaps are ~0.022 (plus up to the chord tolerance) wider than the
|
||||||
|
required spacing, to stay valid under circumscribed-polygon validators. That's negligible in mm
|
||||||
|
and about 0.02" in inches. The constants are absolute and assume job units near inch/mm scale.
|
||||||
|
- **Rotation coverage:** `Automatic` parts try at most 8 orientations (fewer when a job has many
|
||||||
|
distinct parts: `48 / partCount`, minimum 2). Free-angle rotations aren't explored beyond the MBR alignment.
|
||||||
|
- **Greedy core:** there is no order/permutation search. The variants and tail re-plan are the only
|
||||||
|
search, and density on small mixed jobs trails what an interlocking-pair filler can reach.
|
||||||
|
- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate`
|
||||||
|
per trial and `PlateCommitted` at the end, with no finer-grained progress.
|
||||||
|
- Parts whose geometry has no readable closed perimeter, or that fit no offered stock at any
|
||||||
|
allowed rotation, are reported unplaced (`NoPlacementFound`) instead of failing the job.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The objective the engine optimizes: sheet area consumed, less the salvage credit for the
|
||||||
|
/// single largest full-width or full-length edge offcut the job's options allow. Packing toward
|
||||||
|
/// one edge (see <see cref="PackAxis"/>) is what makes that offcut large.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SheetEconomics
|
||||||
|
{
|
||||||
|
public static double SheetArea(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||||
|
|
||||||
|
public static double NetArea(NestJobOptions options, SheetFill fill)
|
||||||
|
{
|
||||||
|
var area = SheetArea(fill.Stock);
|
||||||
|
var minimum = options.MinimumSalvageDimension;
|
||||||
|
if (options.SalvageRate <= 0 || minimum <= 0 || fill.Parts.Count == 0)
|
||||||
|
return area;
|
||||||
|
|
||||||
|
var work = FrontierPacker.WorkArea(fill.Stock);
|
||||||
|
var gap = fill.Stock.PartSpacing;
|
||||||
|
var left = fill.Parts.Min(p => p.Left);
|
||||||
|
var right = fill.Parts.Max(p => p.Right);
|
||||||
|
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||||
|
var top = fill.Parts.Max(p => p.Top);
|
||||||
|
var offcuts = new[]
|
||||||
|
{
|
||||||
|
// Box.Length is the X extent, Box.Width the Y extent.
|
||||||
|
(work.Length, bottom - work.Bottom - gap),
|
||||||
|
(work.Length, work.Top - top - gap),
|
||||||
|
(left - work.Left - gap, work.Width),
|
||||||
|
(work.Right - right - gap, work.Width),
|
||||||
|
};
|
||||||
|
var salvage = 0.0;
|
||||||
|
foreach (var (a, b) in offcuts)
|
||||||
|
if (a >= minimum && b >= minimum)
|
||||||
|
salvage = System.Math.Max(salvage, a * b);
|
||||||
|
return area - options.SalvageRate * salvage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55.Tests;
|
||||||
|
|
||||||
|
public class NoFitCacheTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely.
|
||||||
|
[InlineData(-5.0, -5.0)] // A deep inside B.
|
||||||
|
[InlineData(2.0, 0.5)] // Partial overlap.
|
||||||
|
public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy)
|
||||||
|
{
|
||||||
|
var (small, big) = Orientations();
|
||||||
|
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||||
|
|
||||||
|
Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance.
|
||||||
|
[InlineData(0.0, -21.0)] // Below A.
|
||||||
|
[InlineData(-21.0, 0.0)] // Left of A.
|
||||||
|
public void AllowsClearOffsets(double dx, double dy)
|
||||||
|
{
|
||||||
|
var (small, big) = Orientations();
|
||||||
|
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||||
|
|
||||||
|
Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left.</summary>
|
||||||
|
private static (Orientation Small, Orientation Big) Orientations()
|
||||||
|
{
|
||||||
|
var job = new NestJob(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)),
|
||||||
|
new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)),
|
||||||
|
},
|
||||||
|
new[] { new NestPlateStock("s", new Size(100, 100)) }
|
||||||
|
);
|
||||||
|
var types = PartCatalog.Build(job);
|
||||||
|
return (types[0].Orientations.Single(), types[1].Orientations.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Forbidden(Nfp nfp, PointD point)
|
||||||
|
{
|
||||||
|
var winding = 0;
|
||||||
|
foreach (var path in nfp.Region)
|
||||||
|
if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside)
|
||||||
|
winding += Clipper.IsPositive(path) ? 1 : -1;
|
||||||
|
return winding != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PartGeometrySnapshot Snapshot(params (double X, double Y)[] points)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||||
|
foreach (var (x, y) in points.Skip(1))
|
||||||
|
program.Codes.Add(new LinearMove(x, y));
|
||||||
|
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||||
|
return PartGeometrySnapshot.FromProgram(program);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -13,7 +13,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
<ProjectReference Include="../OpenNest.Engine.Sonnet5.csproj" />
|
<ProjectReference Include="../OpenNest.Engine.Opus55.csproj" />
|
||||||
<ProjectReference Include="../../OpenNest.Engine/OpenNest.Engine.csproj" />
|
<ProjectReference Include="../../OpenNest.Engine/OpenNest.Engine.csproj" />
|
||||||
|
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||||
|
<ProjectReference Include="../../OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Benchmark;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Opus55.Tests;
|
||||||
|
|
||||||
|
public class Opus55NestingEngineTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RectanglesFitOnOneSheetWithSpacing()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Part("disc", Disc(3), 10),
|
||||||
|
Part("ell", LShape(12, 8, 4), 10),
|
||||||
|
Part("tri", Triangle(9, 6), 10),
|
||||||
|
Part("slot", Obround(10, 3), 6),
|
||||||
|
},
|
||||||
|
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroSpacingStillKeepsPartsApartForValidation()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) });
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeAndSmallConcavePartsShareASheet()
|
||||||
|
{
|
||||||
|
// End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard).
|
||||||
|
var job = Job(
|
||||||
|
new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) },
|
||||||
|
new[] { Stock("sheet", 25, 45, spacing: 0.25) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PicksTheCheaperSheetWhenItHoldsEverything()
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[] { Part("square", Rectangle(10, 10), 4) },
|
||||||
|
new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpillsOntoAdditionalSheets()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) });
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.True(result.Plates.Count > 1);
|
||||||
|
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
|
||||||
|
var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i);
|
||||||
|
Assert.Equal(Enumerable.Range(0, 25), indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RespectsFixedAndBoundedRotationPolicies()
|
||||||
|
{
|
||||||
|
var fixedPolicy = RotationPolicy.Fixed(0);
|
||||||
|
var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4);
|
||||||
|
var job = Job(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Part("fixed", LShape(10, 6, 3), 8, fixedPolicy),
|
||||||
|
Part("swept", Triangle(8, 5), 8, sweep),
|
||||||
|
},
|
||||||
|
new[] { Stock("sheet", 40, 60, spacing: 0.25) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||||
|
{
|
||||||
|
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
|
||||||
|
Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OversizedPartIsReportedUnplacedWithoutBlockingOthers()
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) },
|
||||||
|
new[] { Stock("sheet", 20, 20) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||||
|
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||||
|
Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopsWhenFiniteStockRunsOut()
|
||||||
|
{
|
||||||
|
var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) });
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
var usage = Assert.Single(result.StockUsage);
|
||||||
|
Assert.Equal(2, usage.Used);
|
||||||
|
Assert.Equal(0, usage.Remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HonorsMaxPlates()
|
||||||
|
{
|
||||||
|
var job = Job(
|
||||||
|
new[] { Part("rect", Rectangle(9, 9), 20) },
|
||||||
|
new[] { Stock("sheet", 20, 20) },
|
||||||
|
new NestJobOptions(maxPlates: 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new Opus55NestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertValid(job, result);
|
||||||
|
Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsDeterministic()
|
||||||
|
{
|
||||||
|
NestJob Build() =>
|
||||||
|
Job(
|
||||||
|
new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) },
|
||||||
|
new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) }
|
||||||
|
);
|
||||||
|
|
||||||
|
var first = new Opus55NestingEngine().Solve(Build());
|
||||||
|
var second = new Opus55NestingEngine().Solve(Build());
|
||||||
|
|
||||||
|
Assert.Equal(Describe(first), Describe(second));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||||
|
{
|
||||||
|
var engine = Activator.CreateInstance(typeof(Opus55NestingEngine));
|
||||||
|
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static string Describe(NestJobResult result) =>
|
||||||
|
string.Join(
|
||||||
|
"|",
|
||||||
|
result.Plates.Select(p =>
|
||||||
|
p.StockId + ":" + string.Join(",", p.Placements.Select(x => $"{x.PartId}#{x.InstanceIndex}@{x.X:R},{x.Y:R},{x.Rotation:R}"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private static void AssertValid(NestJob job, NestJobResult result)
|
||||||
|
{
|
||||||
|
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||||
|
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
||||||
|
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
||||||
|
p => materialized.DrawingsByPartId[p.Id],
|
||||||
|
p => (p.Id, p.Quantity),
|
||||||
|
ReferenceEqualityComparer.Instance
|
||||||
|
);
|
||||||
|
var validation = NestValidator.Validate(runs, requirements);
|
||||||
|
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||||
|
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
||||||
|
|
||||||
|
foreach (var f in result.Fulfillment)
|
||||||
|
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||||
|
new(parts, stock, options);
|
||||||
|
|
||||||
|
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
||||||
|
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
||||||
|
|
||||||
|
/// <param name="width">Y extent.</param>
|
||||||
|
/// <param name="length">X extent.</param>
|
||||||
|
private static NestPlateStock Stock(
|
||||||
|
string id,
|
||||||
|
double width,
|
||||||
|
double length,
|
||||||
|
double spacing = 0,
|
||||||
|
Spacing edge = default,
|
||||||
|
int quadrant = 1,
|
||||||
|
int? quantity = null
|
||||||
|
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||||
|
|
||||||
|
private static Program Polyline(params (double X, double Y)[] points)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||||
|
foreach (var (x, y) in points.Skip(1))
|
||||||
|
program.Codes.Add(new LinearMove(x, y));
|
||||||
|
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||||
|
|
||||||
|
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||||
|
|
||||||
|
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||||
|
|
||||||
|
private static Program Disc(double r)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(r, 0));
|
||||||
|
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||||
|
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||||
|
private static Program Obround(double length, double width)
|
||||||
|
{
|
||||||
|
var r = width / 2;
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||||
|
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||||
|
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||||
|
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||||
|
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# OpenNest.Engine.Sonnet5
|
|
||||||
|
|
||||||
An independent `INestingEngine` implementation — **not** a wrapper, ensemble, or
|
|
||||||
selector over OpenNest's built-in engines (`StockLadderNestingEngine`,
|
|
||||||
`FixedStrategyNestingEngine` "Default"/"Strip"/"Vertical Remnant"/"Horizontal Remnant`,
|
|
||||||
or anything reachable through `PlateNesterFactory`/`NestingEngineRegistry`).
|
|
||||||
`Solve()` must never call, instantiate, or otherwise delegate a placement decision
|
|
||||||
to one of those.
|
|
||||||
|
|
||||||
## Allowed building blocks
|
|
||||||
|
|
||||||
Low-level geometry/data-structure primitives are fair game — they are not nesting
|
|
||||||
strategies:
|
|
||||||
|
|
||||||
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`,
|
|
||||||
`ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `Collision` (overlap/spacing
|
|
||||||
checks), `NoFitPolygon`, `ShapeProfile`, `SpatialQuery`.
|
|
||||||
- `OpenNest.Engine` support types if useful: `PartBoundary`, `RotationAnalysis`,
|
|
||||||
`AngleCandidateBuilder` — the *decision logic* using them must be your own (don't just
|
|
||||||
call `BestFitFinder`/`PairEvaluator`/`RotationSlideStrategy`, which are the existing
|
|
||||||
best-fit engine's internals).
|
|
||||||
|
|
||||||
## What to fill in
|
|
||||||
|
|
||||||
`Sonnet5NestingEngine.cs` — implement `Solve()`. Pick and document an actual
|
|
||||||
placement strategy (NFP-based sliding placement, skyline/shelf packer,
|
|
||||||
simulated-annealing/genetic layout search, guillotine-cut packer,
|
|
||||||
physics/gravity-settling, etc). It's fine to be simpler or worse than the built-in
|
|
||||||
engines to start; it must not be the same algorithm re-derived through indirection.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
This project is intentionally **outside** `OpenNest.sln` (same pattern as the
|
|
||||||
`OpenNest.Engine.Aurora` plugin) — it's discovered at runtime as a plugin, not built
|
|
||||||
as part of the main solution.
|
|
||||||
|
|
||||||
## Try it out with the benchmark
|
|
||||||
|
|
||||||
`OpenNest.Benchmark` auto-loads plugin engines from an `Engines/` folder next to its
|
|
||||||
own build output:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj -c Release
|
|
||||||
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
|
||||||
|
|
||||||
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
|
|
||||||
cp OpenNest.Engine.Sonnet5/bin/Release/net8.0/OpenNest.Engine.Sonnet5.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
|
||||||
|
|
||||||
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder>
|
|
||||||
```
|
|
||||||
|
|
||||||
Your engine will show up in the report under its CLR type name (`Sonnet5NestingEngine`),
|
|
||||||
competing on equal footing against the built-in engines.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using OpenNest.Engine.Jobs;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.Sonnet5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with
|
|
||||||
/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order
|
|
||||||
/// search", etc). This must be an independently designed algorithm — see README.md.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Sonnet5NestingEngine : INestingEngine
|
|
||||||
{
|
|
||||||
public NestJobResult Solve(
|
|
||||||
NestJob job,
|
|
||||||
IProgress<NestJobProgress>? progress = null,
|
|
||||||
CancellationToken token = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(job);
|
|
||||||
|
|
||||||
// TODO: implement independent placement logic here.
|
|
||||||
//
|
|
||||||
// Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, or any
|
|
||||||
// FixedStrategyNestingEngine / StockLadderNestingEngine instance from inside this
|
|
||||||
// method. Decide placements yourself using OpenNest.Core / OpenNest.Geometry
|
|
||||||
// primitives (Polygon, NoFitPolygon, Collision, ConvexHull, RotatingCalipers, etc).
|
|
||||||
//
|
|
||||||
// job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy)
|
|
||||||
// job.Plates -> candidate stock sheets (size, spacing, quadrant, quantity)
|
|
||||||
// job.Options -> job-wide options
|
|
||||||
//
|
|
||||||
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding
|
|
||||||
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id),
|
|
||||||
// and StockUsage (sheets used per stock id).
|
|
||||||
|
|
||||||
throw new NotImplementedException(
|
|
||||||
"Sonnet5 nesting engine placement logic not yet implemented."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using OpenNest.Engine.Jobs;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.Sonnet5.Tests;
|
|
||||||
|
|
||||||
public class Sonnet5NestingEngineTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void SolveReturnsAResultForASingleSimplePart()
|
|
||||||
{
|
|
||||||
// TODO: replace with a real fixture once Solve() is implemented — this only
|
|
||||||
// proves the plumbing (project reference, constructor, interface) is wired up.
|
|
||||||
var engine = new Sonnet5NestingEngine();
|
|
||||||
|
|
||||||
Assert.NotNull(engine);
|
|
||||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user