Merge branch 'feat/pep-nest-export'

Opus55 NFP nesting engine and the PepNestExport tool for benchmarking
against PEP layouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-23 11:09:17 -04:00
co-authored by Claude Opus 5.5
17 changed files with 2358 additions and 121 deletions
+1
View File
@@ -87,6 +87,7 @@ Compares registered `INestingEngine` implementations against each other on real
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
+272
View File
@@ -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;
}
}
}
+171
View File
@@ -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);
@@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Engine.Sonnet5</RootNamespace>
<AssemblyName>OpenNest.Engine.Sonnet5</AssemblyName>
<RootNamespace>OpenNest.Engine.Opus55</RootNamespace>
<AssemblyName>OpenNest.Engine.Opus55</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
<InternalsVisibleTo Include="OpenNest.Engine.Opus55.Tests" />
<ProjectReference Include="../OpenNest.Engine/OpenNest.Engine.csproj" />
</ItemGroup>
</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);
}
+274
View File
@@ -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;
}
}
+95
View File
@@ -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.
+41
View File
@@ -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);
}
}
@@ -13,7 +13,9 @@
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.Sonnet5.csproj" />
<ProjectReference Include="../OpenNest.Engine.Opus55.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>
</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;
}
}
-57
View File
@@ -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);
}
}
+15
View File
@@ -134,6 +134,21 @@ The settings nest supplies units, material metadata, per-part rotation constrain
The output directory must not exist. The tool writes `imported-cut-only.nest` and `import-report.json` (including input hashes, excluded marks, and unmatched DXFs), then runs the selected engine with a ten-minute cancellation budget. A complete result must pass quantity, bounds, overlap/spacing and cut-only checks, then save/reload and pass them again before success. `validation-report.json` records per-part fulfillment and placements. A partial or invalid result exits nonzero and is not published as a successful nest. Use `import-only` instead of an engine name to verify and save only the imported job. This verifies nesting geometry, not machine-ready CNC lead-ins or post-processing.
### PEP nest export (benchmark against PEP)
`tools/PepNestExport` converts a year of PEP nests into `.nest` files for `OpenNest.Benchmark`. It lists nests from PepApi (`/nests/{year}`), downloads each `.pep` file (`/nests/{year}/{name}/download`), and reads it with `PepLib.Core` from the sibling `PepApi.Core` repo. Override the path with `-p:PepLibProject=<path>` if that repo is cloned elsewhere.
```bash
dotnet run --project tools/PepNestExport -c Release -- "/path/to/PEP 2026 nests" --year 2026
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll "/path/to/PEP 2026 nests" --engines Opus55NestingEngine --csv results.csv
```
Each `.nest` keeps PEP's own layout: plate sizes and duplicate counts, part spacing, edge spacing, quadrant and every placement. The benchmark therefore scores PEP as its `Baseline` row and offers engines only the sheet sizes PEP used, unless you pass `--sheet-sizes`. Drawing geometry comes from PEP's loops, flattened with sub-loop (hole) calls continuing the incremental position. Lead-ins, lead-outs, scribe, display and `DESTRUCT CUT` moves are dropped, and uncut micro-joint tabs of 0.25 or less are closed: open cut runs are chained end to start across the tab and bridged with a cut line, but only where they form a closed loop, so separate contours that happen to lie close together are never merged. The tabs themselves are not kept. Skeleton and display-only parts are excluded.
By default `--quantity nested` sets demand to what PEP actually nested; `--quantity required` uses PEP's required counts instead. Other options: `--nests`, `--status` (default: every status except `Deleted`), `--parallel` and `--force`.
The tool writes `pep-baseline.csv` (sheets, sheet area, part area and utilization per nest) and a `<nest>.violations.txt` when PEP's layout fails validation. PEP places parts at exactly the nominal spacing and rounds coordinates to about 4 decimals, so the strict benchmark validator usually rejects the PEP baseline. Its sheet count and area still show in the report. `RelaxedValid` repeats the check allowing 0.025 on spacing and 0.001 on edges; a failure there means a real overlap or a genuinely tight manual placement. Validation runs on the saved file and is capped at 60 seconds per nest: `NestValidator` can take minutes on parts with hundreds of outline segments and many holes, and those rows report `timeout`. Programs are stored incremental, like CAD-imported drawings, because the desktop renderer only applies part locations to incremental programs.
### Run
```bash
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<!-- PepLib.Core lives in the PepApi.Core repo; override with -p:PepLibProject=<path> if it is cloned elsewhere. -->
<PepLibProject Condition="'$(PepLibProject)' == ''">$(MSBuildThisFileDirectory)..\..\..\PepApi.Core\PepLib.Core\PepLib.Core.csproj</PepLibProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
<ProjectReference Include="$(PepLibProject)" />
</ItemGroup>
</Project>
+825
View File
@@ -0,0 +1,825 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using PepCodes = PepLib.Codes;
using PepModels = PepLib.Models;
using PepVector = PepLib.Geometry.Vector;
// Converts PEP nests (downloaded through PepApi, parsed with PepLib) into OpenNest .nest files
// for OpenNest.Benchmark. Each .nest keeps PEP's own layout - sheet sizes, duplicates, part
// spacing, edge spacing, quadrant and every placement - so the benchmark scores PEP as its
// "Baseline" row and offers engines exactly the sheet sizes PEP used.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
var options = Options.Parse(args);
if (options == null)
{
Console.Error.WriteLine(
"""
Usage: PepNestExport <output-directory> [options]
--year <yyyy> PEP year to export (default 2026)
--api <url> PepApi base URL (default http://10.10.100.134:8085)
--nests N1,N2,... Only these nest names (default: every nest in the year)
--status S1,S2,... Only these PEP statuses, e.g. "Has been cut,To be cut"
(default: every status except Deleted)
--quantity nested|required Part demand written to the .nest (default nested):
nested = what PEP actually nested, so the PEP layout is a
valid, fully placed baseline
required = PEP's required qty; where PEP over-nested, the
baseline is flagged over-quantity
--parallel <n> Nests converted at once (default 4)
--force Re-download and re-convert nests that already exist
Writes <output>/<nest>.nest, caches the raw files in <output>/pep/, and writes a
per-nest summary to <output>/pep-baseline.csv. Benchmark the output folder with:
OpenNest.Benchmark <output-directory> --engines Opus55 --csv results.csv
"""
);
return 1;
}
Directory.CreateDirectory(options.OutputDirectory);
var pepDirectory = Path.Combine(options.OutputDirectory, "pep");
Directory.CreateDirectory(pepDirectory);
using var http = new HttpClient
{
BaseAddress = new Uri(options.ApiBaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromMinutes(2),
};
var json = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
List<NestSummary> summaries;
try
{
summaries =
await http.GetFromJsonAsync<List<NestSummary>>($"nests/{options.Year}", json)
?? new List<NestSummary>();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Could not list {options.Year} nests from {http.BaseAddress}: {ex.Message}");
return 1;
}
var selected = summaries
.Where(s => options.Nests.Count == 0 || options.Nests.Contains(s.Name))
.Where(s =>
options.Statuses.Count > 0
? options.Statuses.Contains(s.Status)
: !string.Equals(s.Status, "Deleted", StringComparison.OrdinalIgnoreCase)
)
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var missingNests = options.Nests.Except(summaries.Select(s => s.Name), StringComparer.OrdinalIgnoreCase);
foreach (var name in missingNests)
Console.Error.WriteLine($"Warning: {name} is not a {options.Year} nest in PepApi.");
Console.WriteLine(
$"{summaries.Count} nests in {options.Year}; converting {selected.Count} (quantity = {options.Quantity})."
);
var rows = new ConcurrentBag<ReportRow>();
var completed = 0;
await Parallel.ForEachAsync(
selected,
new ParallelOptions { MaxDegreeOfParallelism = options.Parallel },
async (summary, cancellation) =>
{
var row = new ReportRow { Nest = summary.Name, PepStatus = summary.Status };
try
{
var nestPath = Path.Combine(options.OutputDirectory, summary.Name + ".nest");
if (File.Exists(nestPath) && !options.Force)
{
row.Result = "skipped (exists; --force to redo)";
}
else
{
var pepPath = Path.Combine(pepDirectory, summary.Name + ".pep");
if (!File.Exists(pepPath) || options.Force)
{
var url = $"nests/{options.Year}/{Uri.EscapeDataString(summary.Name)}/download";
var bytes = await http.GetByteArrayAsync(url, cancellation);
await File.WriteAllBytesAsync(pepPath, bytes, cancellation);
}
PepModels.Nest pep;
using (var stream = File.OpenRead(pepPath))
pep = PepModels.Nest.Load(stream);
PepConverter.Convert(summary, pep, options.Quantity, row, nestPath);
}
}
catch (Exception ex)
{
row.Result = "error: " + ex.Message.ReplaceLineEndings(" ");
}
rows.Add(row);
var done = Interlocked.Increment(ref completed);
Console.WriteLine($"[{done}/{selected.Count}] {row.Nest}: {row.Result}");
}
);
var ordered = rows.OrderBy(r => r.Nest, StringComparer.OrdinalIgnoreCase).ToList();
var csvPath = Path.Combine(options.OutputDirectory, "pep-baseline.csv");
ReportRow.WriteCsv(csvPath, ordered);
var converted = ordered.Where(r => r.Result == "ok").ToList();
Console.WriteLine();
Console.WriteLine(
$"Converted {converted.Count} (with geometry warnings: {converted.Count(r => r.GeometryWarnings.Count > 0)}; "
+ $"PEP layout fails relaxed check: {converted.Count(r => !r.ValidationTimedOut && !r.RelaxedValid)}; "
+ $"fails strict benchmark check: {converted.Count(r => !r.ValidationTimedOut && !r.BaselineValid)}; "
+ $"validation timed out: {converted.Count(r => r.ValidationTimedOut)}), "
+ $"skipped {ordered.Count(r => r.Result.StartsWith("skipped"))}, "
+ $"errors {ordered.Count(r => r.Result.StartsWith("error"))}."
);
if (converted.Count > 0)
{
var sheet = converted.Sum(r => r.SheetArea);
var part = converted.Sum(r => r.PartArea);
Console.WriteLine(
$"PEP across converted nests: {converted.Sum(r => r.Sheets)} sheets, {sheet:F0} sq in of sheet, "
+ $"{part:F0} sq in of parts, {100 * part / sheet:F1}% utilization."
);
}
Console.WriteLine($"Summary: {csvPath}");
return 0;
static class PepConverter
{
public static void Convert(
NestSummary summary,
PepModels.Nest pep,
QuantityMode quantityMode,
ReportRow row,
string nestPath
)
{
var required = pep
.Drawings.GroupBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Sum(d => d.QtyRequired), StringComparer.OrdinalIgnoreCase);
var pepPlates = pep
.Plates.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(p => (Plate: p, Parts: p.Parts.Where(IsRealPart).ToList()))
.Where(p => p.Parts.Count > 0)
.ToList();
if (pepPlates.Count == 0)
{
row.Result = "skipped (no nested parts)";
return;
}
var first = pepPlates[0].Plate;
var nest = new Nest(summary.Name)
{
Units = Units.Inches,
Customer = summary.Customer,
Thickness = first.Thickness,
Material = new Material(summary.MaterialNumber.ToString(), summary.MaterialGrade),
DateCreated = summary.DateCreated,
DateLastModified = DateTime.Now,
};
var drawings = new Dictionary<string, Drawing>(StringComparer.OrdinalIgnoreCase);
var drawingBoxes = new Dictionary<string, Box>(StringComparer.OrdinalIgnoreCase);
var loopShapes = new Dictionary<string, (OpenNest.CNC.Program Program, Box CutBox)>();
var warnings = new List<string>();
foreach (var (pepPlate, pepParts) in pepPlates)
{
// PEP "PLATE SCALING = 60X120" is Y x X; OpenNest Size is (Width = Y, Length = X).
var plate = new Plate(pepPlate.Size.Height, pepPlate.Size.Width)
{
Quantity = System.Math.Max(1, pepPlate.Duplicates),
Quadrant = pepPlate.Quadrant is >= 1 and <= 4 ? pepPlate.Quadrant : 1,
PartSpacing = pepPlate.PartSpacing,
EdgeSpacing = new Spacing(
pepPlate.EdgeSpacing.Left,
pepPlate.EdgeSpacing.Bottom,
pepPlate.EdgeSpacing.Right,
pepPlate.EdgeSpacing.Top
),
};
foreach (var pepPart in pepParts)
{
if (!loopShapes.TryGetValue(pepPart.Name, out var shape))
{
var loop =
pep.Loops.FirstOrDefault(l => l.Name == pepPart.Name)
?? throw new InvalidDataException($"Loop {pepPart.Name} not found");
var program = ToProgram(loop);
shape = (program, CutBox(program));
loopShapes.Add(pepPart.Name, shape);
}
if (!drawings.TryGetValue(pepPart.DrawingName, out var drawing))
{
drawing = new Drawing(pepPart.DrawingName, shape.Program)
{
Customer = summary.Customer,
Material = nest.Material,
Color = Drawing.GetNextColor(),
};
drawings.Add(pepPart.DrawingName, drawing);
drawingBoxes.Add(pepPart.DrawingName, shape.CutBox);
nest.Drawings.Add(drawing);
if (!HasClosedPerimeter(shape.Program))
warnings.Add($"{pepPart.DrawingName}: no closed outer contour; engines cannot place it");
}
// PEP can place one drawing through several loops, each starting at its own
// pierce point, so each loop's frame is a translation of the drawing's.
var drawingBox = drawingBoxes[pepPart.DrawingName];
var delta = new Vector(
shape.CutBox.Left - drawingBox.Left,
shape.CutBox.Bottom - drawingBox.Bottom
);
if (
System.Math.Abs(shape.CutBox.Width - drawingBox.Width) > 0.01
|| System.Math.Abs(shape.CutBox.Length - drawingBox.Length) > 0.01
)
{
warnings.Add($"{pepPart.DrawingName}: loop {pepPart.Name} differs in size from the drawing's first loop");
}
// Same convention in both systems: rotate the program about its origin, then
// place that origin at the part location.
var part = new Part(drawing);
if (!OpenNest.Math.Tolerance.IsEqualTo(pepPart.Rotation, 0))
part.Rotate(pepPart.Rotation);
part.Location = new Vector(pepPart.Location.X, pepPart.Location.Y) + delta.Rotate(pepPart.Rotation);
plate.Parts.Add(part);
}
nest.Plates.Add(plate);
}
nest.PlateDefaults.SetFromExisting(nest.Plates[0]);
nest.UpdateDrawingQuantities();
foreach (var drawing in nest.Drawings)
{
var pepRequired = required.GetValueOrDefault(drawing.Name);
var nested = drawing.Quantity.Nested;
drawing.Quantity.Required =
quantityMode == QuantityMode.Required && pepRequired > 0 ? pepRequired : nested;
if (pepRequired != nested)
row.QuantityMismatches.Add($"{drawing.Name} req {pepRequired} nested {nested}");
}
var unnested = required
.Where(r => r.Value > 0 && !drawings.ContainsKey(r.Key) && !IsSkeleton(r.Key))
.Select(r => r.Key)
.ToList();
foreach (var name in unnested)
row.QuantityMismatches.Add($"{name} req {required[name]} nested 0 (no geometry; not exported)");
nest.Notes =
$"Converted from PEP {summary.Name} ({summary.Status}; {summary.Comments}). "
+ $"Plates are PEP's own layout. Quantities = PEP {quantityMode.ToString().ToLowerInvariant()} counts.";
new NestWriter(nest).Write(nestPath);
// Report on the saved file (the writer rounds coordinates), which is what the benchmark reads.
FillReport(new NestReader(nestPath).Read(), row, warnings);
var violationsPath = Path.ChangeExtension(nestPath, ".violations.txt");
if (row.Violations.Count > 0)
File.WriteAllLines(violationsPath, row.Violations);
else
File.Delete(violationsPath);
row.Result = "ok";
}
private static void FillReport(Nest nest, ReportRow row, List<string> warnings)
{
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
foreach (var plate in nest.Plates)
for (var copy = 0; copy < plate.Quantity; copy++)
plateRuns.Add((plate, plate.Parts.ToList()));
var requirements = nest.Drawings.ToDictionary(
d => d,
d => (d.Name, d.Quantity.Required)
);
// PEP stores placements to ~4 decimals and spaces parts at exactly the nominal gap, which
// the validator's circumscribed arc polygons read as slightly short. A relaxed pass
// separates that from real conversion problems (overlaps, parts off the sheet).
var relaxedRuns = plateRuns
.Select(run =>
{
var edge = run.Plate.EdgeSpacing;
var relaxed = new Plate(run.Plate.Size)
{
Quadrant = run.Plate.Quadrant,
PartSpacing = System.Math.Max(0, run.Plate.PartSpacing - RelaxedSpacingTolerance),
EdgeSpacing = new Spacing(
System.Math.Max(0, edge.Left - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Bottom - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Right - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Top - RelaxedEdgeTolerance)
),
};
return (relaxed, run.Parts);
})
.ToList();
row.Material = $"{nest.Material.Name} {nest.Material.Grade} {nest.Thickness:0.###}";
row.Drawings = nest.Drawings.Count;
row.PartsRequested = nest.Drawings.Sum(d => d.Quantity.Required);
row.PartsNested = nest.Drawings.Sum(d => d.Quantity.Nested);
row.Sheets = nest.Plates.Sum(p => p.Quantity);
row.SheetSizes = string.Join(
" ",
nest.Plates.GroupBy(p => (p.Size.Width, p.Size.Length))
.Select(g => $"{g.Key.Width:0.###}x{g.Key.Length:0.###}*{g.Sum(p => p.Quantity)}")
);
row.PartSpacing = string.Join(" ", nest.Plates.Select(p => p.PartSpacing.ToString("0.###")).Distinct());
row.EdgeSpacing = string.Join(
" ",
nest.Plates.Select(p =>
$"{p.EdgeSpacing.Left:0.###}/{p.EdgeSpacing.Bottom:0.###}/{p.EdgeSpacing.Right:0.###}/{p.EdgeSpacing.Top:0.###}"
)
.Distinct()
);
row.SheetArea = nest.Plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
row.PartArea = nest.Drawings.Sum(d => d.Area * d.Quantity.Nested);
// NestValidator can take minutes on parts with hundreds of outline segments and many
// holes; don't let one nest stall the batch. An abandoned check keeps running on a
// pool thread until the process exits.
var check = Task.Run(() =>
(
Strict: NestValidator.Validate(plateRuns, requirements),
Relaxed: NestValidator.Validate(relaxedRuns, requirements)
)
);
row.GeometryWarnings = warnings;
if (!check.Wait(ValidationTimeout))
{
row.ValidationTimedOut = true;
row.Violations = warnings
.Prepend($"validation timed out after {ValidationTimeout.TotalSeconds:0}s (layout not checked)")
.ToList();
return;
}
var (validation, relaxedValidation) = check.Result;
row.BaselineValid = validation.Valid;
row.StrictViolations = validation.Violations.Count;
row.RelaxedValid = relaxedValidation.Valid;
row.Violations = relaxedValidation
.Violations.Select(v => "relaxed: " + v)
.Concat(warnings)
.Concat(validation.Violations.Select(v => "strict: " + v))
.ToList();
}
private const double RelaxedSpacingTolerance = 0.025;
private const double RelaxedEdgeTolerance = 0.001;
private static readonly TimeSpan ValidationTimeout = TimeSpan.FromSeconds(60);
private static Box CutBox(OpenNest.CNC.Program program) =>
OpenNest.Converters.ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.Cast<IBoundable>()
.GetBoundingBox();
private static bool HasClosedPerimeter(OpenNest.CNC.Program program)
{
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return entities.Count > 0 && new ShapeProfile(entities).Perimeter?.Area() > 1e-9;
}
private static bool IsRealPart(PepModels.Part part) =>
!part.IsDisplayOnly && !string.IsNullOrWhiteSpace(part.DrawingName) && !IsSkeleton(part.DrawingName);
private static bool IsSkeleton(string name) =>
name.StartsWith("Skeleton", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Flattens a PEP loop (incremental, with sub-loop calls for holes) into an absolute OpenNest
/// program in the loop's own frame. Only contour cuts are kept as cut motion; display, scribe,
/// lead-in/out and destruct (slug-chopping) moves become rapids so they never shape the part
/// for nesting. Contours PEP leaves open by a micro-joint are closed.
/// </summary>
private static OpenNest.CNC.Program ToProgram(PepModels.Loop loop)
{
var codes = new List<ICode>();
Emit(loop, new PepVector(0, 0), codes);
var program = new OpenNest.CNC.Program(Mode.Absolute);
program.Codes.AddRange(CollapseRapids(CloseMicroJoints(codes)));
// Built absolute, stored incremental like CAD-imported drawings: the desktop renderer
// only applies a part's location to incremental programs.
program.Mode = Mode.Incremental;
return program;
}
/// <summary>
/// Replaces each run of non-cut moves with one rapid onto the next contour's start and drops
/// trailing ones. Lead-in/out endpoints lie outside the part and a program's bounding box
/// counts rapid endpoints, so leaving them in would inflate the part for edge checks.
/// </summary>
private static IEnumerable<ICode> CollapseRapids(IEnumerable<ICode> codes)
{
var pos = new Vector(0, 0);
var pendingRapid = false;
foreach (var code in codes)
{
if (code is LinearMove or ArcMove)
{
if (pendingRapid)
yield return new RapidMove(pos);
pendingRapid = false;
yield return code;
}
else if (code is Motion)
{
pendingRapid = true;
}
if (code is Motion motion)
pos = motion.EndPoint;
}
}
/// <summary>Largest uncut tab (micro-joint) gap that is bridged to close a contour.</summary>
private const double MaxMicroJointGap = 0.25;
/// <summary>
/// PEP leaves tabs uncut to hold parts and cutouts in place: the cut stops, jumps the tab
/// with a rapid, and carries on (e.g. a cutout cut as two halves 0.02 apart, or an outer
/// contour stopping 0.03 short of its start). The part still occupies that material, so
/// open cut runs are chained end to start across gaps up to <see cref="MaxMicroJointGap"/>
/// and each chain that closes into a loop gets a cut line across every tab. Links are
/// matched shortest gap first, and only closed loops are kept, so separate contours that
/// happen to lie close together (closed ones never take part) are not merged.
/// </summary>
private static IEnumerable<ICode> CloseMicroJoints(List<ICode> codes)
{
var runs = SplitCutRuns(codes);
var open = Enumerable
.Range(0, runs.Count)
.Where(i => runs[i].Start.DistanceTo(runs[i].End) > OpenNest.Math.Tolerance.Epsilon)
.ToList();
var next = new Dictionary<int, int>();
var previous = new Dictionary<int, int>();
var links =
from i in open
from j in open
let gap = runs[i].End.DistanceTo(runs[j].Start)
where gap <= MaxMicroJointGap
orderby gap
select (From: i, To: j);
foreach (var (from, to) in links)
{
if (next.ContainsKey(from) || previous.ContainsKey(to))
continue;
next[from] = to;
previous[to] = from;
}
// Keep only links that close a loop; a chain that dead-ends is left as it was.
var cycleOf = new Dictionary<int, List<int>>();
foreach (var first in open.Where(next.ContainsKey))
{
if (cycleOf.ContainsKey(first))
continue;
var cycle = new List<int> { first };
var current = next[first];
while (current != first && next.TryGetValue(current, out var following) && !cycle.Contains(current))
{
cycle.Add(current);
current = following;
}
if (current != first)
continue;
foreach (var index in cycle)
cycleOf[index] = cycle;
}
var emitted = new HashSet<int>();
for (var i = 0; i < runs.Count; i++)
{
if (emitted.Contains(i))
continue;
if (!cycleOf.TryGetValue(i, out var cycle))
{
emitted.Add(i);
yield return new RapidMove(runs[i].Start);
foreach (var code in runs[i].Codes)
yield return code;
continue;
}
// Start the loop at the run that comes first in the program.
var offset = cycle.IndexOf(i);
yield return new RapidMove(runs[i].Start);
for (var k = 0; k < cycle.Count; k++)
{
var run = runs[cycle[(offset + k) % cycle.Count]];
var following = runs[cycle[(offset + k + 1) % cycle.Count]];
emitted.Add(cycle[(offset + k) % cycle.Count]);
foreach (var code in run.Codes)
yield return code;
if (run.End.DistanceTo(following.Start) > OpenNest.Math.Tolerance.Epsilon)
yield return new LinearMove(following.Start) { Layer = LayerType.Cut };
}
}
}
private sealed record CutRun(Vector Start, Vector End, List<ICode> Codes);
/// <summary>
/// Splits an absolute program into runs of consecutive cut moves. Everything else only
/// positions the head, and <see cref="CollapseRapids"/> rebuilds it afterwards.
/// </summary>
private static List<CutRun> SplitCutRuns(List<ICode> codes)
{
var runs = new List<CutRun>();
var pos = new Vector(0, 0);
List<ICode> current = null;
var start = pos;
foreach (var code in codes)
{
if (code is LinearMove or ArcMove)
{
if (current == null)
{
current = new List<ICode>();
start = pos;
}
current.Add(code);
}
else if (current != null)
{
runs.Add(new CutRun(start, pos, current));
current = null;
}
if (code is Motion motion)
pos = motion.EndPoint;
}
if (current != null)
runs.Add(new CutRun(start, pos, current));
return runs;
}
private static PepVector Emit(PepModels.Program source, PepVector start, List<ICode> codes)
{
var pos = start;
var inDestructCut = false;
foreach (var code in source)
{
switch (code)
{
case PepCodes.Comment comment:
if (comment.Value.StartsWith("DESTRUCT CUT START", StringComparison.OrdinalIgnoreCase))
inDestructCut = true;
else if (comment.Value.StartsWith("DESTRUCT CUT END", StringComparison.OrdinalIgnoreCase))
inDestructCut = false;
break;
case PepCodes.RapidMove rapid:
pos = Advance(pos, rapid.EndPoint, source.Mode);
codes.Add(new RapidMove(ToVector(pos)));
break;
case PepCodes.LinearMove line:
pos = Advance(pos, line.EndPoint, source.Mode);
codes.Add(
line.Type == PepCodes.EntityType.Cut && !inDestructCut
? new LinearMove(ToVector(pos)) { Layer = LayerType.Cut }
: new RapidMove(ToVector(pos))
);
break;
case PepCodes.CircularMove arc:
var arcStart = pos;
pos = Advance(pos, arc.EndPoint, source.Mode);
var center = EquidistantCenter(arcStart, pos, Advance(arcStart, arc.CenterPoint, source.Mode));
codes.Add(
arc.Type == PepCodes.EntityType.Cut && !inDestructCut
? new ArcMove(
ToVector(pos),
ToVector(center),
arc.Rotation == PepLib.Enums.RotationType.CW
? RotationType.CW
: RotationType.CCW
)
{
Layer = LayerType.Cut,
}
: new RapidMove(ToVector(pos))
);
break;
case PepCodes.SubProgramCall call when call.Loop != null:
// Incremental position carries through the sub-loop: the caller resumes
// where the sub-loop ended (e.g. identical holes called 13.8125 apart after
// a 0.1875 lead-in sit on a 14.000 pitch).
pos = Emit(call.Loop, pos, codes);
break;
}
}
return pos;
}
/// <summary>
/// PEP stores some small arcs with a center that is not equidistant from both ends (e.g. a
/// 0.06 notch with radii 0.0300 and 0.0298). OpenNest rebuilds the arc from its end point, so
/// its start would miss the previous move and break the contour. Projecting the center onto
/// the chord's perpendicular bisector keeps both endpoints exact. Full circles are unchanged.
/// </summary>
private static PepVector EquidistantCenter(PepVector start, PepVector end, PepVector center)
{
var chordX = end.X - start.X;
var chordY = end.Y - start.Y;
var chord = System.Math.Sqrt(chordX * chordX + chordY * chordY);
if (chord < 1e-9)
return center;
var midX = (start.X + end.X) / 2;
var midY = (start.Y + end.Y) / 2;
var normalX = -chordY / chord;
var normalY = chordX / chord;
var along = (center.X - midX) * normalX + (center.Y - midY) * normalY;
return new PepVector(midX + along * normalX, midY + along * normalY);
}
private static PepVector Advance(PepVector current, PepVector offset, PepLib.Enums.ProgrammingMode mode) =>
mode == PepLib.Enums.ProgrammingMode.Incremental ? current + offset : offset;
private static Vector ToVector(PepVector v) => new(v.X, v.Y);
}
enum QuantityMode
{
Nested,
Required,
}
sealed record NestSummary(
string Name,
DateTime DateCreated,
string Status,
string Comments,
string Customer,
int MaterialNumber,
string MaterialGrade,
string Application
);
sealed class Options
{
public string OutputDirectory;
public int Year = 2026;
public string ApiBaseUrl = "http://10.10.100.134:8085";
public HashSet<string> Nests = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> Statuses = new(StringComparer.OrdinalIgnoreCase);
public QuantityMode Quantity = QuantityMode.Nested;
public int Parallel = 4;
public bool Force;
public static Options Parse(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--year" when i + 1 < args.Length:
o.Year = int.Parse(args[++i], CultureInfo.InvariantCulture);
break;
case "--api" when i + 1 < args.Length:
o.ApiBaseUrl = args[++i];
break;
case "--nests" when i + 1 < args.Length:
o.Nests.UnionWith(SplitList(args[++i]));
break;
case "--status" when i + 1 < args.Length:
o.Statuses.UnionWith(SplitList(args[++i]));
break;
case "--quantity" when i + 1 < args.Length:
if (!Enum.TryParse(args[++i], ignoreCase: true, out o.Quantity))
return null;
break;
case "--parallel" when i + 1 < args.Length:
o.Parallel = System.Math.Max(1, int.Parse(args[++i], CultureInfo.InvariantCulture));
break;
case "--force":
o.Force = true;
break;
default:
if (args[i].StartsWith("--") || o.OutputDirectory != null)
return null;
o.OutputDirectory = Path.GetFullPath(args[i]);
break;
}
}
return o.OutputDirectory == null ? null : o;
}
private static IEnumerable<string> SplitList(string value) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
sealed class ReportRow
{
public string Nest;
public string PepStatus;
public string Result = "";
public string Material = "";
public int Drawings;
public int PartsRequested;
public int PartsNested;
public int Sheets;
public string SheetSizes = "";
public string PartSpacing = "";
public string EdgeSpacing = "";
public double SheetArea;
public double PartArea;
public bool BaselineValid;
public int StrictViolations;
public bool RelaxedValid;
public bool ValidationTimedOut;
public List<string> Violations = new();
public List<string> GeometryWarnings = new();
public List<string> QuantityMismatches = new();
public static void WriteCsv(string path, IEnumerable<ReportRow> rows)
{
var sb = new StringBuilder();
sb.AppendLine(
"Nest,PepStatus,Result,Material,Drawings,PartsRequested,PartsNested,Sheets,SheetSizes,"
+ "PartSpacing,EdgeSpacing(L/B/R/T),SheetArea,PartArea,Utilization%,RelaxedValid,"
+ "StrictValid,StrictViolations,GeometryWarnings,Violations,QuantityMismatches"
);
foreach (var r in rows)
{
var utilization = r.SheetArea > 0 ? 100 * r.PartArea / r.SheetArea : 0;
sb.AppendLine(
string.Join(
",",
Csv(r.Nest),
Csv(r.PepStatus),
Csv(r.Result),
Csv(r.Material),
r.Drawings,
r.PartsRequested,
r.PartsNested,
r.Sheets,
Csv(r.SheetSizes),
Csv(r.PartSpacing),
Csv(r.EdgeSpacing),
r.SheetArea.ToString("F2"),
r.PartArea.ToString("F2"),
utilization.ToString("F2"),
r.Result != "ok" ? "" : r.ValidationTimedOut ? "timeout" : r.RelaxedValid.ToString(),
r.Result != "ok" ? "" : r.ValidationTimedOut ? "timeout" : r.BaselineValid.ToString(),
r.Result != "ok" || r.ValidationTimedOut ? "" : r.StrictViolations.ToString(),
Csv(string.Join(" | ", r.GeometryWarnings)),
Csv(string.Join(" | ", r.Violations.Take(5)) + (r.Violations.Count > 5 ? $" | +{r.Violations.Count - 5} more" : "")),
Csv(string.Join(" | ", r.QuantityMismatches))
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string Csv(string value) =>
value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0
? "\"" + value.Replace("\"", "\"\"") + "\""
: value;
}