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:
@@ -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/`.
|
||||
|
||||
@@ -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">
|
||||
<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);
|
||||
}
|
||||
@@ -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>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user