refactor: rename Astra engine to Gpt6Astra
OpenAI reuses model codenames across generations (GPT-6 reused GPT-5.6's Sol and Luna), so a bare codename like "Astra" can't identify which model built the engine. Prefixing the model version keeps engine names unambiguous as more runs are added. The CLR type is now Gpt6AstraNestingEngine, so benchmark reports show the new name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra;
|
||||
|
||||
/// <summary>Per-solve configuration-space cache, never a shared mutable geometry cache.</summary>
|
||||
internal sealed class ContactGeometry
|
||||
{
|
||||
private readonly Dictionary<(int, int, double), PathsD> cache = new();
|
||||
|
||||
internal PathsD Forbidden(ShapeVariant stationary, ShapeVariant moving, double spacing, CancellationToken token)
|
||||
{
|
||||
var key = (stationary.Id, moving.Id, spacing);
|
||||
if (cache.TryGetValue(key, out var value)) return value;
|
||||
token.ThrowIfCancellationRequested();
|
||||
PathsD paths;
|
||||
if (stationary.BoxLike && moving.BoxLike)
|
||||
{
|
||||
// Exact axis-aligned rectangle contacts need four configuration-space
|
||||
// vertices, not hundreds of round-offset samples. The square corner is
|
||||
// conservative for diagonal clearance and leaves row/column fits exact.
|
||||
var gap = spacing;
|
||||
paths = new PathsD { new PathD {
|
||||
new(-moving.Width - gap, -moving.Height - gap),
|
||||
new(stationary.Width + gap, -moving.Height - gap),
|
||||
new(stationary.Width + gap, stationary.Height + gap),
|
||||
new(-moving.Width - gap, stationary.Height + gap) } };
|
||||
if (cache.Count >= 8192) cache.Clear();
|
||||
return cache[key] = paths;
|
||||
}
|
||||
if (stationary.Convex && moving.Convex)
|
||||
{
|
||||
var nfp = NoFitPolygon.ComputeConvex(stationary.Hull, moving.Hull);
|
||||
paths = new PathsD { ClipperBridge.ToPath(nfp, positive: true) };
|
||||
}
|
||||
else
|
||||
{
|
||||
// Minkowski edge quads may enclose spurious interior voids. Filling all
|
||||
// positive outer paths is conservative for solid perimeter nesting; real
|
||||
// part holes are searched separately and checked against material regions.
|
||||
var a = ToInteger(stationary.ContactOutline, false);
|
||||
var b = ToInteger(moving.ContactOutline, true);
|
||||
var sum = Clipper.MinkowskiSum(b, a, true);
|
||||
paths = new PathsD(sum.Where(Clipper.IsPositive).Select(path => new PathD(
|
||||
path.Select(p => new PointD(p.X / GeometryPrecision.Scale, p.Y / GeometryPrecision.Scale)))));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
var delta = spacing + stationary.ContactError + moving.ContactError
|
||||
+ (stationary.Curved || moving.Curved ? 0.003 : spacing > 0 ? 0.0003 : 0);
|
||||
if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round,
|
||||
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
|
||||
// Bound cache residency for jobs with many distinct rotation pairs.
|
||||
if (cache.Count >= 8192) cache.Clear();
|
||||
return cache[key] = paths;
|
||||
}
|
||||
|
||||
private static Path64 ToInteger(Polygon polygon, bool reflect)
|
||||
{
|
||||
var scale = reflect ? -GeometryPrecision.Scale : GeometryPrecision.Scale;
|
||||
var path = ClipperBridge.ToPath(polygon, positive: true);
|
||||
return new Path64(path.Select(p => new Point64((long)M.Round(p.x * scale), (long)M.Round(p.y * scale))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra;
|
||||
|
||||
internal sealed record PackedShape(ShapeVariant Variant, double X, double Y);
|
||||
internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape> Shapes, double Area, double Span);
|
||||
|
||||
/// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
|
||||
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token)
|
||||
{
|
||||
private readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new();
|
||||
private double validationOriginX;
|
||||
private double validationOriginY;
|
||||
|
||||
internal SheetTrial Pack(int stockIndex, NestPlateStock stock, int[] committed, int[] flexibility, int mode)
|
||||
{
|
||||
validationOriginX = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
|
||||
validationOriginY = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
|
||||
var width = stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right;
|
||||
var height = stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top;
|
||||
var counts = (int[])committed.Clone();
|
||||
var placed = new List<PackedShape>();
|
||||
var spaces = new Dictionary<int, SearchSpace>();
|
||||
var order = Enumerable.Range(0, parts.Length)
|
||||
.OrderByDescending(i => parts[i].Requirement.Priority)
|
||||
.ThenBy(i => flexibility[i])
|
||||
.ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min())
|
||||
.ThenBy(i => i).ToArray();
|
||||
double area = 0, right = 0, top = 0;
|
||||
foreach (var p in order)
|
||||
{
|
||||
while (counts[p] < parts[p].Requirement.Quantity)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PackedShape? best = null;
|
||||
(double, double, double, double) bestScore = (double.MaxValue, 0, 0, 0);
|
||||
foreach (var v in parts[p].Variants)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (v.Width > width + 1e-9 || v.Height > height + 1e-9) continue;
|
||||
var pose = Find(v, placed, width, height, stock.PartSpacing, mode, right, top, spaces);
|
||||
if (pose == null) continue;
|
||||
var score = Score(pose, width, height, mode, right, top);
|
||||
if (score.CompareTo(bestScore) < 0) { best = pose; bestScore = score; }
|
||||
}
|
||||
if (best == null) break;
|
||||
placed.Add(best);
|
||||
counts[p]++;
|
||||
area += parts[p].Area;
|
||||
right = M.Max(right, best.X + best.Variant.Width);
|
||||
top = M.Max(top, best.Y + best.Variant.Height);
|
||||
}
|
||||
}
|
||||
return new(stockIndex, counts, placed, area, right * top);
|
||||
}
|
||||
|
||||
private PackedShape? Find(ShapeVariant moving, List<PackedShape> placed, double width, double height,
|
||||
double spacing, int mode, double right, double top, Dictionary<int, SearchSpace> spaces)
|
||||
{
|
||||
var maxX = M.Max(0, width - moving.Width);
|
||||
var maxY = M.Max(0, height - moving.Height);
|
||||
if (!spaces.TryGetValue(moving.Id, out var space))
|
||||
{
|
||||
space = new SearchSpace();
|
||||
space.Anchors.AddRange(new PointD[] { new(0, 0), new(maxX, 0), new(0, maxY), new(maxX, maxY) });
|
||||
space.Free.Add(new PathD { new(0, 0), new(maxX, 0), new(maxX, maxY), new(0, maxY) });
|
||||
spaces.Add(moving.Id, space);
|
||||
}
|
||||
var points = space.Anchors;
|
||||
var forbidden = new PathsD();
|
||||
var blockers = space.Blockers;
|
||||
foreach (var other in placed.Skip(space.Processed))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = GeometryPrecision.Translate(geometry.Forbidden(other.Variant, moving, spacing, token), other.X, other.Y);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
forbidden.Add(path);
|
||||
if (!other.Variant.Material.Any(p => !Clipper.IsPositive(p)))
|
||||
blockers.Add((path, path.Min(p => p.x), path.Min(p => p.y), path.Max(p => p.x), path.Max(p => p.y)));
|
||||
// Clipping loses zero-area feasible regions. Retain their NFP vertices
|
||||
// and intersections with plate boundaries explicitly for exact fits.
|
||||
for (var i = 0; (maxX < 1e-8 || maxY < 1e-8) && i < path.Count; i++)
|
||||
{
|
||||
var a = path[i]; var b = path[(i + 1) % path.Count];
|
||||
Add(a.x, a.y);
|
||||
CrossX(0); CrossX(maxX); CrossY(0); CrossY(maxY);
|
||||
void CrossX(double x)
|
||||
{
|
||||
if (M.Abs(b.x - a.x) < 1e-12) return;
|
||||
var t = (x - a.x) / (b.x - a.x);
|
||||
if (t >= 0 && t <= 1) Add(x, a.y + t * (b.y - a.y));
|
||||
}
|
||||
void CrossY(double y)
|
||||
{
|
||||
if (M.Abs(b.y - a.y) < 1e-12) return;
|
||||
var t = (y - a.y) / (b.y - a.y);
|
||||
if (t >= 0 && t <= 1) Add(a.x + t * (b.x - a.x), y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Axis contacts also cover exact spacing when the padded NFP cannot fit.
|
||||
foreach (var x in new[] { other.X, other.X + other.Variant.Width + spacing,
|
||||
other.X - moving.Width - spacing })
|
||||
foreach (var y in new[] { 0, other.Y, other.Y + other.Variant.Height + spacing,
|
||||
other.Y - moving.Height - spacing }) Add(x, y);
|
||||
|
||||
// The solid-outline NFP deliberately fills holes. Search each real hole
|
||||
// separately, then validate against material, not the outer envelope.
|
||||
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p)))
|
||||
{
|
||||
var l = hole.Min(p => p.x) + other.X + spacing + 0.0004;
|
||||
var b = hole.Min(p => p.y) + other.Y + spacing + 0.0004;
|
||||
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - 0.0004;
|
||||
var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - 0.0004;
|
||||
if (r < l || t < b) continue;
|
||||
Add(l, b); Add(r, b); Add(l, t); Add(r, t); Add((l + r) / 2, (b + t) / 2);
|
||||
// Box corners miss the useful interior of circular and rounded holes.
|
||||
// Interior samples also cover fits that require an off-center placement.
|
||||
foreach (var fx in new[] { 0.25, 0.5, 0.75 })
|
||||
foreach (var fy in new[] { 0.25, 0.5, 0.75 }) Add(l + fx * (r - l), b + fy * (t - b));
|
||||
}
|
||||
}
|
||||
if (placed.Count > 0 && maxX > 1e-8 && maxY > 1e-8)
|
||||
{
|
||||
space.Free = Clipper.Difference(space.Free, forbidden, FillRule.NonZero, GeometryPrecision.Digits);
|
||||
}
|
||||
space.Processed = placed.Count;
|
||||
points = new List<PointD>(space.Anchors);
|
||||
foreach (var path in space.Free) foreach (var p in path) Add(p.x, p.y);
|
||||
var seen = new HashSet<(long, long)>();
|
||||
foreach (var pose in points.Select(p => new PackedShape(moving, p.x, p.y))
|
||||
.OrderBy(p => Score(p, width, height, mode, right, top)))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!seen.Add(((long)M.Round(pose.X * 1e6), (long)M.Round(pose.Y * 1e6)))) continue;
|
||||
if (blockers.Any(b => pose.X > b.L && pose.X < b.R && pose.Y > b.B && pose.Y < b.T &&
|
||||
StrictlyInside(b.Path, pose.X, pose.Y))) continue;
|
||||
if (Valid(pose, placed, spacing)) return pose;
|
||||
if (spacing > 0) continue;
|
||||
// Exact contacts can be invalid only after the host's four-decimal
|
||||
// polygon rounding. Try nearby outward contacts without changing angle.
|
||||
foreach (var (dx, dy) in new (double, double)[] {
|
||||
(0.0003, 0), (0, 0.0003), (0.0003, 0.0003), (-0.0003, 0),
|
||||
(0, -0.0003), (-0.0003, 0.0003), (0.0003, -0.0003), (-0.0003, -0.0003) })
|
||||
{
|
||||
var nudged = pose with { X = pose.X + dx, Y = pose.Y + dy };
|
||||
if (nudged.X < 0 || nudged.Y < 0 || nudged.X > maxX || nudged.Y > maxY) continue;
|
||||
if (Valid(nudged, placed, spacing)) return nudged;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
void Add(double x, double y)
|
||||
{
|
||||
if (x < -1e-7 || y < -1e-7 || x > maxX + 1e-7 || y > maxY + 1e-7) return;
|
||||
points.Add(new(M.Clamp(x, 0, maxX), M.Clamp(y, 0, maxY)));
|
||||
}
|
||||
}
|
||||
|
||||
private static (double, double, double, double) Score(PackedShape pose, double width, double height,
|
||||
int mode, double right, double top)
|
||||
{
|
||||
var r = pose.X + pose.Variant.Width;
|
||||
var t = pose.Y + pose.Variant.Height;
|
||||
// Two directional searches use the same configuration-space algorithm. The
|
||||
// third objective minimizes the growing used rectangle rather than a strip.
|
||||
return mode switch
|
||||
{
|
||||
1 => (r + 0.01 * t * width / height, pose.Y, pose.X, pose.Variant.Width * pose.Variant.Height),
|
||||
2 => (M.Max(right, r) * M.Max(top, t), t, r, pose.Variant.Width * pose.Variant.Height),
|
||||
_ => (t + 0.01 * r * height / width, pose.X, pose.Y, pose.Variant.Width * pose.Variant.Height)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class SearchSpace
|
||||
{
|
||||
internal int Processed;
|
||||
internal PathsD Free = new();
|
||||
internal List<PointD> Anchors = new();
|
||||
internal List<(PathD Path, double L, double B, double R, double T)> Blockers = new();
|
||||
}
|
||||
|
||||
private static bool StrictlyInside(PathD path, double x, double y)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = path[i]; var b = path[(i + 1) % path.Count];
|
||||
var cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x);
|
||||
if (M.Abs(cross) <= 2e-6 * M.Max(1, M.Abs(b.x - a.x) + M.Abs(b.y - a.y)) &&
|
||||
x >= M.Min(a.x, b.x) - 1e-6 && x <= M.Max(a.x, b.x) + 1e-6 &&
|
||||
y >= M.Min(a.y, b.y) - 1e-6 && y <= M.Max(a.y, b.y) + 1e-6) return false;
|
||||
if ((a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
|
||||
{
|
||||
PathsD? material = null;
|
||||
PathsD? validationMaterial = null;
|
||||
foreach (var other in placed)
|
||||
{
|
||||
var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? 0.003 : 0.0001);
|
||||
if (candidate.X >= other.X + other.Variant.Width + gap ||
|
||||
other.X >= candidate.X + candidate.Variant.Width + gap ||
|
||||
candidate.Y >= other.Y + other.Variant.Height + gap ||
|
||||
other.Y >= candidate.Y + candidate.Variant.Height + gap) continue;
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (candidate.Variant.BoxLike && other.Variant.BoxLike)
|
||||
{
|
||||
if (candidate.X >= other.X + other.Variant.Width + spacing - 1e-9 ||
|
||||
other.X >= candidate.X + candidate.Variant.Width + spacing - 1e-9 ||
|
||||
candidate.Y >= other.Y + other.Variant.Height + spacing - 1e-9 ||
|
||||
other.Y >= candidate.Y + candidate.Variant.Height + spacing - 1e-9) continue;
|
||||
return false;
|
||||
}
|
||||
material ??= GeometryPrecision.Translate(candidate.Variant.Material, candidate.X, candidate.Y);
|
||||
var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
|
||||
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
|
||||
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false;
|
||||
validationMaterial ??= GeometryPrecision.Translate(candidate.Variant.ValidationRegion(0), candidate.X, candidate.Y);
|
||||
var validationObstacle = GeometryPrecision.Translate(other.Variant.ValidationRegion(spacing), other.X, other.Y);
|
||||
if (M.Abs(Clipper.Area(Clipper.Intersect(validationMaterial, validationObstacle,
|
||||
FillRule.NonZero, GeometryPrecision.Digits))) > 1e-8) return false;
|
||||
if (spacing == 0 || candidate.Variant.Material.Count > 1 || other.Variant.Material.Count > 1)
|
||||
{
|
||||
var outerIntersection = Clipper.Intersect(
|
||||
new PathsD(validationMaterial.Where(Clipper.IsPositive)),
|
||||
new PathsD(validationObstacle.Where(Clipper.IsPositive)), FillRule.NonZero, GeometryPrecision.Digits);
|
||||
if (spacing != 0 && M.Abs(Clipper.Area(outerIntersection)) <= 1e-8) continue;
|
||||
var key = (candidate.Variant.Id, other.Variant.Id, spacing,
|
||||
candidate.X + validationOriginX, candidate.Y + validationOriginY,
|
||||
other.X + validationOriginX, other.Y + validationOriginY);
|
||||
if (!validationCache.TryGetValue(key, out var collides))
|
||||
{
|
||||
collides = ValidationOverlap(
|
||||
GeometryPrecision.Translate(validationMaterial, validationOriginX, validationOriginY),
|
||||
GeometryPrecision.Translate(validationObstacle, validationOriginX, validationOriginY));
|
||||
if (validationCache.Count >= 4096) validationCache.Clear();
|
||||
validationCache[key] = collides;
|
||||
}
|
||||
if (collides) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static bool ValidationOverlap(PathsD a, PathsD b)
|
||||
{
|
||||
var holesA = a.Where(p => !Clipper.IsPositive(p)).Select(ClipperBridge.ToPolygon).ToList();
|
||||
var holesB = b.Where(p => !Clipper.IsPositive(p)).Select(ClipperBridge.ToPolygon).ToList();
|
||||
foreach (var outerA in a.Where(Clipper.IsPositive))
|
||||
foreach (var outerB in b.Where(Clipper.IsPositive))
|
||||
{
|
||||
var pa = ClipperBridge.ToPolygon(outerA);
|
||||
var pb = ClipperBridge.ToPolygon(outerB);
|
||||
// The benchmark orders by world-space left bound before clipping.
|
||||
if (pa.Left <= pb.Left ? Collision.HasOverlap(pa, pb, holesA, holesB) :
|
||||
Collision.HasOverlap(pb, pa, holesB, holesA)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra;
|
||||
|
||||
/// <summary>Independent configuration-space contact packing with bounded stock-plan search.</summary>
|
||||
public sealed class Gpt6AstraNestingEngine : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var parts = GeometryPreparation.Prepare(job, token);
|
||||
var fit = parts.Select(p => job.Plates.Select(s => p.Variants.Any(v =>
|
||||
v.Width <= s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right + 1e-9 &&
|
||||
v.Height <= s.Size.Width - s.EdgeSpacing.Top - s.EdgeSpacing.Bottom + 1e-9)).ToArray()).ToArray();
|
||||
var placer = new ContactPlacer(parts, new ContactGeometry(), token);
|
||||
var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
|
||||
var frontier = new List<Plan> { initial };
|
||||
var best = initial;
|
||||
Plan? complete = IsComplete(initial) ? initial : null;
|
||||
var trials = new Dictionary<string, SheetTrial>(StringComparer.Ordinal);
|
||||
var priorities = job.Parts.Select(p => p.Priority).Distinct().OrderDescending().ToArray();
|
||||
var evaluated = 0;
|
||||
var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray();
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var children = new Dictionary<string, Plan>(StringComparer.Ordinal);
|
||||
foreach (var state in frontier)
|
||||
{
|
||||
if (state.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue)) continue;
|
||||
var lowerBound = LowerBound(state);
|
||||
if (complete != null && lowerBound >= complete.Cost - 1e-7) continue;
|
||||
var flexibility = Enumerable.Range(0, parts.Length).Select(p =>
|
||||
Enumerable.Range(0, job.Plates.Count).Count(s => fit[p][s] && Available(state, s))).ToArray();
|
||||
for (var s = 0; s < job.Plates.Count; s++)
|
||||
{
|
||||
if (!Available(state, s)) continue;
|
||||
// Search breadth is work-count bounded, never elapsed-time dependent.
|
||||
// Past this budget, continue filling greedily instead of abandoning demand.
|
||||
var modes = evaluated < 24 ? 2 : 1;
|
||||
for (var mode = 0; mode < modes; mode++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var key = $"{s}/{mode}/{string.Join(',', state.Counts)}/{string.Join(',', flexibility)}";
|
||||
if (!trials.TryGetValue(key, out var trial))
|
||||
{
|
||||
progress?.Report(new(NestJobStage.EvaluatingCandidate, job.Plates[s].Id,
|
||||
state.Sheets.Count, 0, 0));
|
||||
trial = placer.Pack(s, job.Plates[s], state.Counts, flexibility, mode);
|
||||
if (trials.Count >= 256) trials.Clear();
|
||||
trials[key] = trial;
|
||||
evaluated++;
|
||||
}
|
||||
if (trial.Shapes.Count == 0) continue;
|
||||
var sheetCost = job.Plates[s].Size.Length * job.Plates[s].Size.Width;
|
||||
for (var p = 0; p < parts.Length; p++)
|
||||
{
|
||||
var delivered = trial.Counts[p] - state.Counts[p];
|
||||
if (delivered > 0) unitCosts[p] = M.Min(unitCosts[p], sheetCost / delivered);
|
||||
}
|
||||
var used = (int[])state.Used.Clone(); used[s]++;
|
||||
var sheets = new List<SheetTrial>(state.Sheets) { trial };
|
||||
var next = new Plan(trial.Counts, used, sheets,
|
||||
state.Cost + job.Plates[s].Size.Length * job.Plates[s].Size.Width);
|
||||
if (BetterFulfillment(next, best)) best = next;
|
||||
if (IsComplete(next))
|
||||
{
|
||||
if (complete == null || next.Cost < complete.Cost - 1e-7 ||
|
||||
(M.Abs(next.Cost - complete.Cost) < 1e-7 && next.Sheets.Count < complete.Sheets.Count)) complete = next;
|
||||
continue;
|
||||
}
|
||||
var stateKey = $"{string.Join(',', next.Counts)}/{string.Join(',', next.Used)}";
|
||||
if (!children.TryGetValue(stateKey, out var prior) || next.Cost < prior.Cost)
|
||||
children[stateKey] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
var ranked = children.Values.Where(p => complete == null || LowerBound(p) < complete.Cost - 1e-7)
|
||||
.OrderBy(Estimate).ThenByDescending(PlacedArea).ThenBy(p => p.Cost).ToList();
|
||||
frontier = new List<Plan>();
|
||||
if (ranked.Count > 0)
|
||||
{
|
||||
frontier.Add(ranked[0]);
|
||||
// A material-only lower bound favors cheap small-sheet prefixes and
|
||||
// can discard every high-throughput plan. Preserve one progress leader.
|
||||
var leader = ranked.OrderByDescending(PlacedArea).ThenBy(p => p.Cost).First();
|
||||
if (!ReferenceEquals(leader, ranked[0])) frontier.Add(leader);
|
||||
foreach (var candidate in ranked)
|
||||
{
|
||||
if (frontier.Count >= (evaluated < 64 ? 3 : 2)) break;
|
||||
if (!frontier.Contains(candidate)) frontier.Add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
var selected = complete ?? best;
|
||||
var counts = new int[parts.Length];
|
||||
var plates = new List<NestJobPlateResult>();
|
||||
foreach (var sheet in selected.Sheets)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var stock = job.Plates[sheet.StockIndex];
|
||||
var x = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
|
||||
var y = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
|
||||
var placements = sheet.Shapes.Select(p => new NestJobPlacement(job.Parts[p.Variant.Part].Id,
|
||||
counts[p.Variant.Part]++, x + p.X - p.Variant.OriginX,
|
||||
y + p.Y - p.Variant.OriginY, p.Variant.Angle)).ToArray();
|
||||
plates.Add(new(plates.Count, stock, placements));
|
||||
progress?.Report(new(NestJobStage.PlateCommitted, stock.Id, plates.Count - 1,
|
||||
plates.Count, counts.Sum()));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
var reason = complete != null ? NestJobStopReason.Completed :
|
||||
selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached :
|
||||
!Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted :
|
||||
NestJobStopReason.NoPlacementFound;
|
||||
return new(complete != null ? NestJobStatus.Complete : NestJobStatus.Incomplete, reason, plates,
|
||||
job.Parts.Select((p, i) => new PartFulfillment(p.Id, p.Quantity, counts[i], p.Quantity - counts[i])),
|
||||
job.Plates.Select((s, i) => new StockUsage(s.Id, selected.Used[i], s.Quantity - selected.Used[i])));
|
||||
|
||||
bool Available(Plan p, int s) => p.Used[s] < (job.Plates[s].Quantity ?? int.MaxValue);
|
||||
bool IsComplete(Plan p) => parts.Select((part, i) => p.Counts[i] == part.Requirement.Quantity).All(v => v);
|
||||
double PlacedArea(Plan p) => parts.Select((part, i) => p.Counts[i] * part.Area).Sum();
|
||||
double LowerBound(Plan p) => p.Cost + parts.Select((part, i) =>
|
||||
(part.Requirement.Quantity - p.Counts[i]) * part.Area).Sum();
|
||||
double Estimate(Plan p)
|
||||
{
|
||||
var projected = 0.0;
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
if (double.IsFinite(unitCosts[i])) projected = M.Max(projected,
|
||||
(parts[i].Requirement.Quantity - p.Counts[i]) * unitCosts[i]);
|
||||
return M.Max(LowerBound(p), p.Cost + projected);
|
||||
}
|
||||
bool BetterFulfillment(Plan a, Plan b)
|
||||
{
|
||||
foreach (var priority in priorities)
|
||||
{
|
||||
var ac = parts.Select((p, i) => p.Requirement.Priority == priority ? a.Counts[i] : 0).Sum();
|
||||
var bc = parts.Select((p, i) => p.Requirement.Priority == priority ? b.Counts[i] : 0).Sum();
|
||||
if (ac != bc) return ac > bc;
|
||||
}
|
||||
return a.Cost < b.Cost;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Plan(int[] Counts, int[] Used, List<SheetTrial> Sheets, double Cost);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests/**/*.cs;benchmarks/**/*.cs" />
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Core/OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,187 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra;
|
||||
|
||||
internal sealed record PreparedPart(NestJobPart Requirement, double Area, ShapeVariant[] Variants);
|
||||
|
||||
internal sealed class ShapeVariant
|
||||
{
|
||||
internal required int Id { get; init; }
|
||||
internal required int Part { get; init; }
|
||||
internal required double Angle { get; init; }
|
||||
internal required double OriginX { get; init; }
|
||||
internal required double OriginY { get; init; }
|
||||
internal required double Width { get; init; }
|
||||
internal required double Height { get; init; }
|
||||
internal required bool Curved { get; init; }
|
||||
internal required PathsD Material { get; init; }
|
||||
internal required Polygon Outline { get; init; }
|
||||
internal required Polygon ContactOutline { get; init; }
|
||||
internal required double ContactError { get; init; }
|
||||
internal required Polygon Hull { get; init; }
|
||||
internal required bool Convex { get; init; }
|
||||
internal required ShapeProfile ValidationProfile { get; init; }
|
||||
internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
|
||||
GridAligned(Width) && GridAligned(Height) &&
|
||||
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * Height);
|
||||
private static bool GridAligned(double x) => M.Abs(x - M.Round(x * 10000) / 10000) < 1e-9;
|
||||
private readonly Dictionary<double, PathsD> validationRegions = new();
|
||||
|
||||
internal PathsD ValidationRegion(double spacing)
|
||||
{
|
||||
if (validationRegions.TryGetValue(spacing, out var cached)) return cached;
|
||||
// Match the external validator's sequence: flatten/round in the original
|
||||
// rotated snapshot frame, then translate. Rounding after normalization is
|
||||
// not equivalent at a zero-clearance contact.
|
||||
var region = ClipperBridge.OffsetForValidation(ValidationProfile, spacing, 0.001);
|
||||
var paths = new PathsD(region.Outers.Select(p => ClipperBridge.ToPath(p, true)));
|
||||
paths.AddRange(region.Holes.Select(p => ClipperBridge.ToPath(p, false)));
|
||||
return validationRegions[spacing] = GeometryPrecision.Translate(paths, -OriginX, -OriginY);
|
||||
}
|
||||
private readonly Dictionary<double, PathsD> halos = new();
|
||||
|
||||
internal PathsD Halo(double spacing)
|
||||
{
|
||||
if (halos.TryGetValue(spacing, out var cached)) return cached;
|
||||
// Raw outlines already circumscribe curves; the extra clearance covers independent
|
||||
// flattenings after pose materialization and the validator's four-decimal grid.
|
||||
var delta = spacing + (Curved ? 0.0021 : spacing > 0 ? 0.00015 : 0);
|
||||
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
|
||||
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class GeometryPrecision
|
||||
{
|
||||
internal const int Digits = 6;
|
||||
internal const double Scale = 1_000_000;
|
||||
internal const double Epsilon = 0.000002;
|
||||
|
||||
internal static PathsD Translate(PathsD paths, double x, double y) =>
|
||||
new(paths.Select(path => new PathD(path.Select(p => new PointD(p.x + x, p.y + y)))));
|
||||
|
||||
internal static PathsD FromPolygons(IEnumerable<Polygon> polygons, bool positive) =>
|
||||
new(polygons.Select(p => ClipperBridge.ToPath(p, positive)));
|
||||
}
|
||||
|
||||
internal static class GeometryPreparation
|
||||
{
|
||||
internal static PreparedPart[] Prepare(NestJob job, CancellationToken token)
|
||||
{
|
||||
var id = 0;
|
||||
return job.Parts.Select((part, index) =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToList();
|
||||
// Input validation has established that open marks lie inside material. They
|
||||
// must not be interpreted as holes by ShapeProfile.
|
||||
var closed = ShapeBuilder.GetShapes(entities).Where(s => s.IsClosed())
|
||||
.SelectMany(s => s.Entities).ToList();
|
||||
var baseProfile = new ShapeProfile(closed);
|
||||
var area = baseProfile.Perimeter.Area() - baseProfile.Cutouts.Sum(h => h.Area());
|
||||
var variants = new List<ShapeVariant>();
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var angle in Angles(part.Rotation, baseProfile))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList();
|
||||
var x = rotated.Min(e => e.Left);
|
||||
var y = rotated.Min(e => e.Bottom);
|
||||
var w = rotated.Max(e => e.Right) - x;
|
||||
var h = rotated.Max(e => e.Top) - y;
|
||||
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
|
||||
throw new ArgumentException($"Unusable rotated bounds: {part.Id}.");
|
||||
var validationProfile = new ShapeProfile(rotated.Select(e => e.Clone()).ToList());
|
||||
foreach (var e in rotated) e.Offset(-x, -y);
|
||||
var profile = new ShapeProfile(rotated);
|
||||
var material = ClipperBridge.ToRegion(profile, 0.001, circumscribe: true);
|
||||
// Circular/symmetric parts should not multiply identical NFP work. Compare
|
||||
// normalized closed contours, including holes, independent of start vertex.
|
||||
var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal));
|
||||
if (!keys.Add(key)) continue;
|
||||
var outline = ClipperBridge.Flatten(profile.Perimeter, 0.001, circumscribe: true);
|
||||
var hull = ConvexHull.Compute(outline.Vertices);
|
||||
var convex = M.Abs(hull.Area() - outline.Area()) < 1e-7 * M.Max(1, hull.Area());
|
||||
// Concave Minkowski sums have quadratic input size. Only the contact
|
||||
// proposal outline is simplified; fine material remains the safety gate.
|
||||
// Pad the resulting NFP by both approximation error bounds.
|
||||
var contactError = !convex && outline.Vertices.Count > 64 ? M.Max(0.002, M.Min(w, h) * 0.002) : 0;
|
||||
var contactOutline = contactError == 0 ? outline :
|
||||
ClipperBridge.Flatten(profile.Perimeter, contactError, circumscribe: true);
|
||||
variants.Add(new ShapeVariant { Id = id++, Part = index, Angle = angle,
|
||||
OriginX = x, OriginY = y, Width = w, Height = h,
|
||||
Curved = rotated.Any(e => e is Arc or Circle), Material = material,
|
||||
Outline = outline, ContactOutline = contactOutline, ContactError = contactError,
|
||||
Hull = hull, Convex = convex, ValidationProfile = validationProfile });
|
||||
}
|
||||
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
|
||||
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
|
||||
{
|
||||
var minimum = ordered[0].Width * ordered[0].Height;
|
||||
var all = ordered;
|
||||
var shortlist = ordered.Where(v => v.Width * v.Height <= minimum * 1.08 + 1e-7).Take(16).ToList();
|
||||
// A diagonal may be the only orientation fitting a narrow stock. Never
|
||||
// discard every fitting orientation merely because its envelope is larger.
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
bool Fits(ShapeVariant v) => v.Width <= stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right + 1e-9 &&
|
||||
v.Height <= stock.Size.Width - stock.EdgeSpacing.Top - stock.EdgeSpacing.Bottom + 1e-9;
|
||||
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
|
||||
}
|
||||
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
|
||||
}
|
||||
return new PreparedPart(part, area, ordered);
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static string Canonical(PathD path)
|
||||
{
|
||||
if (path.Count == 0) return "";
|
||||
var points = path.Select(p => ((long)M.Round(p.x * 100000), (long)M.Round(p.y * 100000))).ToArray();
|
||||
var first = 0;
|
||||
for (var i = 1; i < points.Length; i++) if (points[i].CompareTo(points[first]) < 0) first = i;
|
||||
return string.Join(";", Enumerable.Range(0, points.Length).Select(i => points[(i + first) % points.Length]));
|
||||
}
|
||||
|
||||
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
|
||||
{
|
||||
var values = new List<double>();
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
// All half-turns matter for asymmetric parts, unlike envelope-only packing.
|
||||
for (var i = 0; i < 24; i++) values.Add(i * M.PI / 12);
|
||||
foreach (var line in profile.Perimeter.Entities.OfType<Line>().OrderByDescending(l => l.Length).Take(8))
|
||||
{
|
||||
var angle = -M.Atan2(line.EndPoint.Y - line.StartPoint.Y, line.EndPoint.X - line.StartPoint.X);
|
||||
for (var i = 0; i < 4; i++) values.Add(angle + i * M.PI / 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var last = policy.Kind == RotationPolicyKind.Fixed ? 0 : M.Floor((policy.End - policy.Start) / policy.Step);
|
||||
if (!double.IsFinite(last)) last = 720;
|
||||
var samples = (int)M.Min(720, last);
|
||||
for (var i = 0; i <= samples; i++)
|
||||
{
|
||||
var k = samples == 0 ? 0 : M.Floor(last * ((double)i / samples));
|
||||
var angle = policy.Start + k * policy.Step;
|
||||
if (!double.IsFinite(angle) || !policy.Allows(angle)) continue;
|
||||
values.Add(angle);
|
||||
if (policy.Allow180Equivalent) values.Add(angle + M.PI);
|
||||
}
|
||||
}
|
||||
var seen = new HashSet<long>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
var angle = value % (2 * M.PI);
|
||||
if (angle < 0) angle += 2 * M.PI;
|
||||
if (policy.Allows(angle) && seen.Add((long)M.Round(angle * 1e9))) yield return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# OpenNest.Engine.Gpt6Astra
|
||||
|
||||
An independent, deterministic .NET 8 CNC nesting plugin. Its public parameterless
|
||||
`Gpt6AstraNestingEngine` implements `INestingEngine`. The project remains outside `OpenNest.sln`.
|
||||
|
||||
## Placement algorithm
|
||||
|
||||
Gpt6Astra searches configuration space: for each stationary/moving orientation pair, a no-fit
|
||||
polygon describes the translations that would overlap. Subtracting these regions from the
|
||||
sheet's usable translation rectangle exposes contact positions where another part can fit.
|
||||
This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles,
|
||||
concave interlocking, and insertion into straight-edged and curved holes.
|
||||
|
||||
1. Validate immutable job input. Reconstruct owned analytic entities with `DrawingJobMapper`
|
||||
and `ConvertProgram`. Closed contours define material; internal open marks do not become
|
||||
holes. Preserve the snapshot's origin when converting normalized placements back to poses.
|
||||
2. Prepare rotated outlines and material regions with holes, using conservative curve flattening.
|
||||
Automatic angles combine 15-degree samples over a full turn with orientations aligned to the
|
||||
longest straight edges. Symmetric duplicates are removed. Prefer up to 16 orientations whose
|
||||
envelope area is within 8% of the minimum; retain additional orientations when needed to fit
|
||||
a candidate stock. Fixed and bounded rotation policies remain enforced. Bounded sweeps use
|
||||
up to 721 integer step indices, including permitted half-turn equivalents.
|
||||
3. Process high-priority parts first, then parts fitting fewer available stock types, then larger
|
||||
envelopes. Larger frames precede inserts. Search every retained orientation for each instance.
|
||||
4. Build cached Minkowski/no-fit regions. Convex pairs use Core's linear convex NFP primitive;
|
||||
concave pairs use Clipper's integer Minkowski sum. Arc-heavy concave contact outlines use
|
||||
a coarser mesh with both approximation bounds added to clearance; fine material geometry
|
||||
still checks every candidate. Positive outer boundaries are filled conservatively.
|
||||
Axis-aligned rectangles have a four-vertex contact shortcut.
|
||||
5. Maintain each orientation's available translation region incrementally as parts are added.
|
||||
Search its boundary vertices, exact-fit contacts and hole anchors. Reject points inside solid
|
||||
no-fit regions before expensive checks. Check surviving candidates against actual material
|
||||
regions with holes and spacing offsets. Zero-clearance contacts also pass the shared triangulated
|
||||
collision check, with tiny position adjustments when rounding makes an exact contact unsafe.
|
||||
Hole contacts use the same check in the final sheet coordinate frame, with bounded caching.
|
||||
Two directional objectives try bottom-up and left-to-right growth using the same contact algorithm.
|
||||
6. Search stock plans with a beam of up to three states. Rank by observed delivery cost and
|
||||
remaining material, while preserving a state with high placed area. This avoids starving
|
||||
large-sheet plans in favor of cheap but inefficient small-sheet prefixes. A genuine material
|
||||
area lower bound prunes plans only once a complete cheaper plan exists. After 24 evaluated
|
||||
trials only one directional objective is used; after 64, beam width reduces to two.
|
||||
Work counts, not elapsed time or randomness, control search breadth.
|
||||
7. Select a complete plan with lowest purchased area, breaking equal-cost ties by sheet count.
|
||||
If no complete plan is found, maximize fulfilled counts by priority, then minimize cost.
|
||||
Emit committed-sheet progress, contiguous per-part instance indices, inventory, fulfillment
|
||||
and the contract's job-level stop reason. Cancellation throws without returning a partial job.
|
||||
|
||||
The engine never invokes another engine, registry, job runner, whole-plate nester or filler.
|
||||
All order, stock, orientation, placement, search and stopping decisions belong to Gpt6Astra.
|
||||
Core geometry and Clipper are primitives, not alternative nesters. A shared Core collision fix
|
||||
corrects curved-hole validation; the placement algorithm remains entirely in Gpt6Astra.
|
||||
|
||||
## Precision and safety
|
||||
|
||||
Analytic rotated bounds govern sheet containment. Material curves are conservatively flattened
|
||||
at 0.001 job units. Positive configuration-space spacing includes 0.0003 extra units for non-rectangular
|
||||
straight outlines; curved outlines reserve 0.003 extra units even at zero spacing, accounting for offset/chord error and the
|
||||
benchmark validator's four-decimal grid. Axis-aligned rectangle contacts preserve exact requested
|
||||
spacing. Actual material intersection checks backstop candidate construction. Both straight-edged
|
||||
and curved holes are available for insertion. The shared collision routine now subtracts hole
|
||||
triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
|
||||
curved-hole false positive. See the benchmark report for regression results.
|
||||
|
||||
Concave contact outlines exceeding 64 vertices use a chord tolerance of the greater of 0.002
|
||||
units or 0.2% of the smaller envelope dimension. The pair's two tolerances are added to the
|
||||
NFP offset. This reduces Minkowski input size without coarsening the final material checks.
|
||||
|
||||
The broad phase is deliberately conservative. It can miss a valid close fit; output validation
|
||||
is exercised separately through the benchmark's materialized geometry validator in tests.
|
||||
|
||||
## Structure
|
||||
|
||||
- `Gpt6AstraNestingEngine.cs`: bounded stock-plan search, accounting, progress and result construction.
|
||||
- `PreparedGeometry.cs`: snapshots, allowed orientations, symmetry reduction and material regions.
|
||||
- `ContactGeometry.cs`: cached no-fit polygons and rectangle specialization.
|
||||
- `ContactPlacer.cs`: incremental available regions, contact/inside-hole search and collision checks.
|
||||
- `tests/`: xUnit tests plus a linked copy of the existing benchmark validator source.
|
||||
- `benchmarks/`: standalone synthetic benchmark driver, reproducible repository-DXF manifests,
|
||||
baseline/current CSV results and comparison notes. It is not compiled into the plugin.
|
||||
|
||||
`OpenNest.Engine.Gpt6Astra.csproj` references Core explicitly and inherits Engine/net8.0 settings from
|
||||
`../Directory.Build.props`. Test and benchmark sources are excluded from the plugin assembly.
|
||||
|
||||
## Build, test and deploy
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.Engine.Gpt6Astra/OpenNest.Engine.Gpt6Astra.csproj -c Release
|
||||
dotnet test OpenNest.Engine.Gpt6Astra/tests/OpenNest.Engine.Gpt6Astra.Tests.csproj -c Release
|
||||
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||
mkdir -p ../OpenNest/OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||
cp OpenNest.Engine.Gpt6Astra/bin/Release/net8.0/OpenNest.Engine.Gpt6Astra.dll ../OpenNest/OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||
dotnet ../OpenNest/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll OpenNest.Engine.Gpt6Astra/benchmarks/dxf --engines Gpt6AstraNestingEngine --parallel 1
|
||||
```
|
||||
|
||||
The host supplies Core, Engine and their dependencies. Plugin discovery uses the CLR type name
|
||||
`Gpt6AstraNestingEngine`; no registry call exists in the plugin.
|
||||
Rebuild the host with this checkout's `OpenNest.Core` as well: replacing only the plugin DLL
|
||||
does not update the shared curved-hole collision fix.
|
||||
|
||||
## Limitations
|
||||
|
||||
This is bounded heuristic search, not a proof of minimum sheet cost or infeasibility. Early part
|
||||
order is not backtracked within a sheet, already placed parts are not moved, and available
|
||||
orientations are sampled/pruned. Hole search uses anchor positions, not a complete inner-fit
|
||||
polygon solver. Small usable regions inside complex cutouts may be missed. The benchmark report
|
||||
includes an isolated host-validator reproducer and its corrected outcomes.
|
||||
Filling NFP interior voids can exclude unusual interlocking configurations.
|
||||
Salvage-credit options and `PlacementStrategy` do not change Gpt6Astra's objective; `MaxPlates` is
|
||||
respected. Stock dimensions, all edge spacings, quadrants, priorities and rotation policies are
|
||||
honored. No real `.nest` fixtures were available in this workspace.
|
||||
|
||||
Complex concave outlines, dense bounded sweeps, many part types or very large quantities can
|
||||
be expensive. NFP and trial cache entry counts are bounded, but individual geometry can be large.
|
||||
Cancellation is checked throughout search and between geometry operations; shared validation and
|
||||
individual Clipper calls are not interruptible. The v2 search costs more CPU than the original
|
||||
bounding-rectangle baseline. See `benchmarks/README.md` for measured tradeoffs.
|
||||
|
||||
## Current validation
|
||||
|
||||
Release build succeeded with .NET SDK 8.0.425 on Linux. All 27 xUnit cases passed, including
|
||||
independent benchmark validation of materialized results, exact positive/zero clearance,
|
||||
non-cardinal rotations, hole insertion, automatic diagonal-only stock fits, all quadrants,
|
||||
curves, incremental geometry, determinism, inventory, cancellation and stock-plan regressions.
|
||||
All 34 synthetic/generated benchmark cases and all four repository-DXF cases were valid and complete.
|
||||
Existing nullable warnings originate from the benchmark validator linked into the test project.
|
||||
@@ -0,0 +1,89 @@
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
using CncProgram = OpenNest.CNC.Program;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra.Benchmarks;
|
||||
|
||||
internal static class GeneratedCases
|
||||
{
|
||||
internal static IEnumerable<(string Name, NestJob Job)> Create()
|
||||
{
|
||||
var library = new ShapeDefinition[] {
|
||||
new RoundedRectangleShape { Length = 11, Width = 5, Radius = 1.8 },
|
||||
new TShape { Width = 10, Height = 9, StemWidth = 2, BarHeight = 2 },
|
||||
new TrapezoidShape { BottomWidth = 10, TopWidth = 3, Height = 6 },
|
||||
new NgonShape { Sides = 5, Width = 6 },
|
||||
new NgonShape { Sides = 6, Width = 6 },
|
||||
new RingShape { OuterDiameter = 10, InnerDiameter = 7 },
|
||||
new PipeFlangeShape { OD = 7.5, HoleDiameter = 0.875, HolePatternDiameter = 5.5,
|
||||
HoleCount = 8, PipeSize = "2", PipeClearance = 0.0625 }
|
||||
};
|
||||
for (var i = 0; i < library.Length; i++)
|
||||
yield return ($"generated-library-{i}-{library[i].Name}", Job(new[] {
|
||||
Part("main", library[i].GetDrawing().Program, i == 6 ? 12 : 24)
|
||||
}, i));
|
||||
yield return ("generated-ring-inserts", Job(new[] {
|
||||
Part("ring", library[5].GetDrawing().Program, 8),
|
||||
Part("insert", new CircleShape { Diameter = 6 }.GetDrawing().Program, 8)
|
||||
}, 1));
|
||||
var curvedC = new CncProgram();
|
||||
curvedC.MoveTo(6, 0); curvedC.ArcTo(0, -6, 0, 0, RotationType.CCW);
|
||||
curvedC.LineTo(0, -4); curvedC.ArcTo(4, 0, 0, 0, RotationType.CW); curvedC.LineTo(6, 0);
|
||||
yield return ("generated-curved-C", Job(new[] { Part("C", curvedC, 16) }, 2));
|
||||
yield return ("generated-narrow-U", Job(new[] { Part("U", Poly(0, 0, 10, 0, 10, 10,
|
||||
8.5, 10, 8.5, 1.5, 1.5, 1.5, 1.5, 10, 0, 10), 24) }, 3));
|
||||
yield return ("generated-stars", Job(new[] { Part("star", Star(7, 6, 2.5), 20) }, 0));
|
||||
for (var seed = 0; seed < 12; seed++)
|
||||
{
|
||||
var random = new Random(19073 + seed);
|
||||
var parts = new List<NestJobPart>();
|
||||
for (var p = 0; p < 4; p++)
|
||||
{
|
||||
CncProgram program;
|
||||
if (p == 0) program = new RoundedRectangleShape { Length = 5 + random.NextDouble() * 7,
|
||||
Width = 3 + random.NextDouble() * 3, Radius = 0.7 }.GetDrawing().Program;
|
||||
else if (p == 1) program = Star(5 + seed % 3, 3 + random.NextDouble() * 2, 1.5 + random.NextDouble());
|
||||
else if (p == 2) program = new TrapezoidShape { BottomWidth = 5 + random.NextDouble() * 5,
|
||||
TopWidth = 2 + random.NextDouble() * 2, Height = 3 + random.NextDouble() * 4 }.GetDrawing().Program;
|
||||
else program = new NgonShape { Sides = 3 + seed % 5, Width = 3 + random.NextDouble() * 3 }.GetDrawing().Program;
|
||||
// Nonzero source origins exercise pose reconstruction as well as shape packing.
|
||||
program.Offset(new Vector(seed * 1.37 - 5, p * 2.13 - 3));
|
||||
var rotation = p == 2 ? RotationPolicy.Fixed((seed % 4) * M.PI / 7) :
|
||||
p == 3 ? RotationPolicy.BoundedSweep(-M.PI / 3, M.PI / 2, M.PI / 6, true) : RotationPolicy.Automatic;
|
||||
parts.Add(Part($"p{p}", program, random.Next(3, 9), rotation));
|
||||
}
|
||||
yield return ($"generated-seed-{seed:00}", Job(parts.ToArray(), seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static NestJob Job(NestJobPart[] parts, int seed) => new(parts, new[] {
|
||||
new NestPlateStock("small", new Size(23 + seed % 3, 41 + seed % 5), 2,
|
||||
seed % 4 == 0 ? 0 : 0.1 + seed % 3 * 0.075, new Spacing(0.2, 0.3, 0.4, 0.5), seed % 4 + 1),
|
||||
new NestPlateStock("large", new Size(47, 83), partSpacing: 0.2,
|
||||
edgeSpacing: new Spacing(0.3, 0.2, 0.5, 0.4), quadrant: seed % 4 + 1)
|
||||
});
|
||||
|
||||
private static NestJobPart Part(string name, CncProgram p, int quantity, RotationPolicy rotation = null) =>
|
||||
new(name, PartGeometrySnapshot.FromProgram(p), quantity, rotation: rotation);
|
||||
|
||||
private static CncProgram Star(int arms, double outer, double inner)
|
||||
{
|
||||
var coordinates = Enumerable.Range(0, arms * 2).SelectMany(i => {
|
||||
var radius = i % 2 == 0 ? outer : inner;
|
||||
var angle = i * M.PI / arms;
|
||||
return new[] { radius * M.Cos(angle), radius * M.Sin(angle) };
|
||||
}).ToArray();
|
||||
return Poly(coordinates);
|
||||
}
|
||||
|
||||
private static CncProgram Poly(params double[] points)
|
||||
{
|
||||
var p = new CncProgram(); p.MoveTo(points[0], points[1]);
|
||||
for (var i = 2; i < points.Length; i += 2) p.LineTo(points[i], points[i + 1]);
|
||||
p.LineTo(points[0], points[1]); return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
|
||||
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Benchmark;
|
||||
using CncProgram = OpenNest.CNC.Program;
|
||||
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
if (args.Contains("--diagnose-ring"))
|
||||
{
|
||||
var ringPart = new NestJobPart("ring", PartGeometrySnapshot.FromProgram(
|
||||
new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing().Program), 1);
|
||||
var insertPart = new NestJobPart("insert", PartGeometrySnapshot.FromProgram(
|
||||
new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing().Program), 1);
|
||||
foreach (var quadrant in new[] { 1, 2 })
|
||||
foreach (var offset in new[] { 0.0, 0.0003, 0.05, 0.1 })
|
||||
{
|
||||
var s = new NestPlateStock("s", new Size(24, 42), 1, 0.175,
|
||||
new Spacing(0.2, 0.3, 0.4, 0.5), quadrant);
|
||||
var j = new NestJob(new[] { ringPart, insertPart }, new[] { s });
|
||||
var x = (quadrant == 1 ? 0 : -42) + s.EdgeSpacing.Left + 5;
|
||||
var y = s.EdgeSpacing.Bottom + 5;
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, s, new[] { new NestJobPlacement("ring", 0, x, y, 0),
|
||||
new NestJobPlacement("insert", 0, x + offset, y, 0) }) },
|
||||
new[] { new PartFulfillment("ring", 1, 1, 0), new PartFulfillment("insert", 1, 1, 0) },
|
||||
new[] { new StockUsage("s", 1, 0) });
|
||||
var materialized = NestResultMaterializer.Materialize(j, result);
|
||||
var check = NestValidator.Validate(materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
|
||||
j.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
|
||||
Console.WriteLine($"q={quadrant} offset={offset} valid={check.Valid}: {string.Join(';', check.Violations)}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
var assembly = args.Length > 0 && args[0].EndsWith(".dll")
|
||||
? new AssemblyLoadContext("benchmark-plugin", isCollectible: true).LoadFromAssemblyPath(Path.GetFullPath(args[0])) : Assembly.Load("OpenNest.Engine.Gpt6Astra");
|
||||
var engine = (INestingEngine)Activator.CreateInstance(assembly.GetType("OpenNest.Engine.Gpt6Astra.Gpt6AstraNestingEngine")!);
|
||||
var cases = new List<(string Name, NestJob Job)>();
|
||||
var standard = new[] { new NestPlateStock("small", new Size(24, 48), partSpacing: 0.15),
|
||||
new NestPlateStock("large", new Size(48, 96), partSpacing: 0.15) };
|
||||
NestJobPart Part(string name, CncProgram p, int count, RotationPolicy rotation = null) =>
|
||||
new(name, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation);
|
||||
CncProgram Polygon(params double[] xy)
|
||||
{
|
||||
var p = new CncProgram(); p.MoveTo(xy[0], xy[1]);
|
||||
for (var i = 2; i < xy.Length; i += 2) p.LineTo(xy[i], xy[i + 1]);
|
||||
p.LineTo(xy[0], xy[1]); return p;
|
||||
}
|
||||
CncProgram Rect(double w, double h) => Polygon(0, 0, w, 0, w, h, 0, h);
|
||||
CncProgram Circle(double r) { var p = new CncProgram(); p.MoveTo(r, 0); p.ArcTo(r, 0, 0, 0, RotationType.CCW); return p; }
|
||||
void Add(string name, NestJobPart[] parts, NestPlateStock[] stocks = null, NestJobOptions options = null) =>
|
||||
cases.Add((name, new NestJob(parts, stocks ?? standard, options)));
|
||||
Add("triangles", new[] { Part("triangle", Polygon(0, 0, 10, 0, 0, 10), 60) });
|
||||
Add("circles", new[] { Part("circle", Circle(2.5), 90) });
|
||||
Add("circles-dense", new[] { Part("circle", Circle(2.5), 80) });
|
||||
Add("concave-L", new[] { Part("L", Polygon(0, 0, 8, 0, 8, 2, 2, 2, 2, 8, 0, 8), 60) });
|
||||
Add("mixed", new[] { Part("rect", Rect(9, 4), 30), Part("triangle", Polygon(0, 0, 8, 0, 3, 6), 25),
|
||||
Part("circle", Circle(2), 30), Part("L", Polygon(0, 0, 7, 0, 7, 2, 2, 2, 2, 6, 0, 6), 20) });
|
||||
var ring = Rect(10, 10); ring.MoveTo(1, 1); ring.LineTo(1, 9); ring.LineTo(9, 9); ring.LineTo(9, 1); ring.LineTo(1, 1);
|
||||
Add("holes", new[] { Part("frame", ring, 8), Part("insert", Rect(7, 7), 8) });
|
||||
Add("rectangles", Enumerable.Range(0, 8).Select(i => Part($"r{i}", Rect(2 + i, 3 + i % 3), 12)).ToArray());
|
||||
Add("grain", new[] { Part("fixed", Rect(13, 3), 20, RotationPolicy.Fixed(System.Math.PI / 6)),
|
||||
Part("sweep", Rect(7, 2), 40, RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4)) });
|
||||
Add("scarce-stock", new[] { Part("small", Rect(4, 4), 8), Part("large", Rect(10, 10), 1) },
|
||||
new[] { new NestPlateStock("scarce", new Size(10, 10), 1), new NestPlateStock("small-only", new Size(4, 8)) });
|
||||
Add("tail", new[] { Part("rect", Rect(6, 4), 17) }, new[] {
|
||||
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1), new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
|
||||
Add("plate-cap", new[] { Part("r", Rect(5, 5), 10) }, new[] {
|
||||
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20)) }, new NestJobOptions(maxPlates: 1));
|
||||
cases.AddRange(OpenNest.Engine.Gpt6Astra.Benchmarks.GeneratedCases.Create());
|
||||
Console.WriteLine("case,valid,placed,requested,sheets,area,milliseconds");
|
||||
foreach (var (name, job) in cases)
|
||||
{
|
||||
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
try
|
||||
{
|
||||
var result = engine.Solve(job, token: cts.Token); sw.Stop();
|
||||
var nest = NestResultMaterializer.Materialize(job, result);
|
||||
var validation = NestValidator.Validate(nest.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
|
||||
job.Parts.ToDictionary(p => nest.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
|
||||
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
if (!validation.Valid) Environment.ExitCode = 1;
|
||||
Console.WriteLine($"{name},{validation.Valid},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width)},{sw.ElapsedMilliseconds}");
|
||||
foreach (var violation in validation.Violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
|
||||
}
|
||||
catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# Gpt6Astra development benchmark report
|
||||
|
||||
Measured locally on Linux with .NET SDK 8.0.425, Release builds, 2026-09-23.
|
||||
The shared-validator fix was verified on 2026-09-24; its results are recorded separately below.
|
||||
The baseline is Gpt6Astra's original independent guillotine/bounding-rectangle implementation,
|
||||
archived before the contact-search rewrite. These are not measurements against Opus or a
|
||||
claim of performance on an unseen competition dataset.
|
||||
|
||||
## Synthetic cases
|
||||
|
||||
Both versions were run on exactly the same programmatically generated geometry and stock.
|
||||
The driver validates materialized output with `OpenNest.Benchmark.NestValidator`, including
|
||||
quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only,
|
||||
exclude external validation, and are single-run observations rather than stable distributions.
|
||||
Every contact result is valid and complete. The baseline is valid but incomplete on `plate-cap`.
|
||||
|
||||
| Case | Baseline area | Contact area | Change | Contact time (ms) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| triangles | 8064 | 4608 | -42.9% | 3354 |
|
||||
| circles | 3456 | 3456 | 0.0% | 1021 |
|
||||
| circles-dense | 3456 | 2304 | -33.3% | 742 |
|
||||
| concave-L | 5760 | 3456 | -40.0% | 1055 |
|
||||
| mixed | 4608 | 3456 | -25.0% | 1990 |
|
||||
| holes | 2304 | 1152 | -50.0% | 306 |
|
||||
| rectangles | 3456 | 3456 | 0.0% | 261 |
|
||||
| grain | 4608 | 2304 | -50.0% | 527 |
|
||||
| scarce-stock | 228 | 228 | 0.0% | 1 |
|
||||
| tail | 600 | 600 | 0.0% | 4 |
|
||||
| plate-cap | 100 | 400 | 4/10 → 10/10 placed | 4 |
|
||||
|
||||
Excluding `plate-cap`, where baseline completion differs, purchased area fell from 36540
|
||||
to 25020: **31.5% less area** across these ten cases. The original solver
|
||||
usually took 0–30 ms; contact search takes approximately 1 ms to 3.4 s on this set. Packing
|
||||
quality improved at a substantial CPU cost. No speedup over the original baseline is claimed.
|
||||
|
||||
## Extended generated cases
|
||||
|
||||
`GeneratedCases.cs` adds 23 jobs: rounded rectangles, T-shapes, trapezoids, pentagons,
|
||||
hexagons, rings, pipe flanges, ring/insert mixtures, curved C-shapes, narrow U-shapes,
|
||||
stars, and 12 seeded mixed jobs. These exercise all four quadrants, asymmetric edge margins,
|
||||
finite small-sheet inventory, translated source origins, zero/positive spacing, fixed
|
||||
non-cardinal rotations and bounded sweeps. The library supplies most shapes; the C, U and
|
||||
star contours are generated directly. Seeded cases use seeds 19073 through 19084.
|
||||
|
||||
Both versions place every requested part in all 23 jobs with valid output. Seven cases use
|
||||
less purchased area; the other sixteen match the baseline. Aggregate area drops from
|
||||
58259 to 44353, **23.9% less area**. The changed cases are:
|
||||
|
||||
| Generated case | Baseline area | Contact area | Reduction |
|
||||
|---|---:|---:|---:|
|
||||
| T-shapes | 5917 | 2016 | 65.9% |
|
||||
| Hexagons | 2160 | 1080 | 50.0% |
|
||||
| Pipe flanges | 1932 | 966 | 50.0% |
|
||||
| Curved C-shapes | 6051 | 2150 | 64.5% |
|
||||
| Narrow U-shapes | 5925 | 3901 | 34.2% |
|
||||
| Seed 19083 | 1968 | 984 | 50.0% |
|
||||
| Seed 19084 | 2100 | 1050 | 50.0% |
|
||||
|
||||
The initial fine-mesh curved-C search exceeded the driver's 90-second cancellation budget
|
||||
(an in-flight Minkowski operation delayed cancellation to 110 seconds). Separately coarsening
|
||||
its contact outline, padding both approximation errors, and retaining fine safety geometry
|
||||
reduced that case to approximately 2.3–2.5 seconds. No wall-clock cutoff was added to the engine.
|
||||
The final regression pass validates all 34 generated/synthetic jobs and all four DXF jobs;
|
||||
27 independent xUnit cases also pass. Raw results are in `results/`.
|
||||
|
||||
## Curved-hole validator fix (2026-09-24)
|
||||
|
||||
The generated ring/insert job exposed a shared-validator false positive. A ring with inner
|
||||
radius 3.5 containing a concentric radius-3 disk has 0.5 units of clearance, yet the host's
|
||||
triangulated collision check can report a violation with required spacing 0.175. The outcome
|
||||
also changes with translations. Checking every candidate against that routine made a small
|
||||
ring job take approximately 80–90 seconds.
|
||||
|
||||
Gpt6Astra initially reserved curved cutouts as solid during placement, preserving the original
|
||||
drawing in output. This workaround finished the ring/insert job in about 50 ms at the baseline
|
||||
sheet cost. It has now been removed following a fix in `OpenNest.Core/Geometry/Collision.cs`.
|
||||
|
||||
Hole subtraction previously clipped each fragment independently against every triangle edge,
|
||||
duplicating surviving area. It also classified points with an epsilon-shifted boundary but
|
||||
intersected against the unshifted line, which could extrapolate outside the source segment.
|
||||
The corrected routine emits disjoint outside fragments and carries the inside remainder to
|
||||
the next edge. Classification and interpolation use the same signed cross products. Exact
|
||||
closing vertices and local-coordinate area checks avoid additional small-fragment errors.
|
||||
This remains the shared hand-written collision algorithm; Clipper is only an independent
|
||||
oracle in the new tests, not a replacement per-pair validator.
|
||||
|
||||
The eight original translated reproductions all pass. Regression coverage also rejects real
|
||||
spacing violations, checks both operand orders and windings, exercises all four quadrants,
|
||||
and compares overlap areas against Clipper on 80 seeded pairs with multiple/concave holes.
|
||||
The main suite passes 1,096 tests (12 font-fixture skips), Engine passes 170, and Gpt6Astra passes
|
||||
27. Gpt6Astra's curved-hole tests now require a ring and insert to share stock whose usable area
|
||||
fits only the ring, proving that insertion is enabled.
|
||||
|
||||
All 34 synthetic/generated jobs remain valid and complete with unchanged sheet-area costs.
|
||||
The ring/insert job with hole search enabled takes about 4.8 seconds in this run and still
|
||||
uses two small sheets. This fix improves validity and enables insertion; it does not improve
|
||||
that job's stock plan. Results are in `results/validator-fixed-synthetic-generated.csv`.
|
||||
All four repository-DXF jobs also remain valid and complete at unchanged sheet-area costs;
|
||||
their rerun is recorded in `results/validator-fixed-dxf.csv`.
|
||||
|
||||
The isolated reproduction does not invoke any nesting engine:
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Engine.Gpt6Astra/benchmarks -c Release -- --diagnose-ring
|
||||
```
|
||||
|
||||
`results/ring-validator-reproducer.txt` retains the original failures;
|
||||
`results/ring-validator-fixed.txt` records the corrected outcomes. Rebuild the host's Core
|
||||
dependency when deploying. The benchmark validator's spacing rules and source are unchanged.
|
||||
|
||||
## Repository DXFs
|
||||
|
||||
The four manifests under `dxf/` use PT45, PT23 and PT11 repository drawings. Every result from
|
||||
both versions was valid and complete. Runs used `--parallel 1`.
|
||||
|
||||
| Manifest | Baseline area | Contact area | Change |
|
||||
|---|---:|---:|---:|
|
||||
| locked.manifest | 115200 | 115200 | 0.0% |
|
||||
| mixed.manifest | 144000 | 115200 | -20.0% |
|
||||
| original.manifest | 115200 | 115200 | 0.0% |
|
||||
| volume.manifest | 374400 | 374400 | 0.0% |
|
||||
|
||||
The mixed three-drawing job improves 20%; the other three retain baseline sheet-area cost.
|
||||
The initial contact version regressed on `volume`; preserving a high-progress beam state and
|
||||
ranking with observed per-part delivery cost removed that regression. Final results purchase
|
||||
720000 area units versus 748800, a 3.8% reduction across the four manifests. Fewer physical
|
||||
sheets sometimes have the same purchased area; those are not counted as area savings.
|
||||
|
||||
An exploratory run of the original manifest against StockLadder and Default found StockLadder
|
||||
valid/complete at the same 115200 area cost; Default's result was flagged for spacing. That
|
||||
single case does not establish general superiority. There is no Opus result available here.
|
||||
|
||||
## Local geometry optimizations
|
||||
|
||||
Apart from the shared Core collision fix described above, these optimizations are in Gpt6Astra.
|
||||
Gpt6Astra caches NFPs and incrementally
|
||||
subtracts each newly placed part from available translation regions, avoiding repeated unions
|
||||
of all previous obstacles. A four-vertex rectangle configuration-space specialization avoids
|
||||
round-offset polygons and polygon collision work for exact axis-aligned rectangle contacts.
|
||||
During development, the 96-rectangle case dropped from roughly 1.5 s to 0.24 s after this
|
||||
specialization. This is an end-to-end observation, not an isolated component microbenchmark.
|
||||
|
||||
Precision regression tests cover exact clearances and rotated zero-spacing contacts. In the
|
||||
latter case, the host's four-decimal polygon rounding and triangulated collision test can
|
||||
reject a contact accepted by Clipper at six decimals. Gpt6Astra now retains the original rotated
|
||||
frame for that validation, checks zero-clearance contacts with Core's collision primitive,
|
||||
and tries tiny nearby translations when exact contact is unsafe.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Engine.Gpt6Astra/benchmarks -c Release
|
||||
```
|
||||
|
||||
Run only the extended generated suite with `-- current generated`; the first positional
|
||||
argument is either a previous plugin DLL or a label for the current build. Invalid layouts
|
||||
and crashes make the driver exit with a nonzero status. Incompleteness is reported separately.
|
||||
|
||||
The standalone driver also accepts a prior plugin DLL and optional case-name filter:
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Engine.Gpt6Astra/benchmarks -c Release -- /path/to/previous/OpenNest.Engine.Gpt6Astra.dll triangles
|
||||
```
|
||||
|
||||
An isolated assembly load context prevents .NET from silently substituting the currently
|
||||
built plugin when comparing another version with the same assembly name. Historical baseline
|
||||
CSV files are included; the old binary is not committed. The driver's 90-second cancellation
|
||||
budget is a benchmark safeguard and is not an elapsed-time stopping rule inside the engine.
|
||||
|
||||
For real DXFs, build and deploy the plugin as described in the parent README, then:
|
||||
|
||||
```bash
|
||||
dotnet ../OpenNest/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll OpenNest.Engine.Gpt6Astra/benchmarks/dxf --engines Gpt6AstraNestingEngine --parallel 1 --csv /tmp/astra-dxf.csv
|
||||
```
|
||||
|
||||
The CSV files under `results/` retain the measured results. Tests run independently:
|
||||
|
||||
```bash
|
||||
dotnet test OpenNest.Engine.Gpt6Astra/tests/OpenNest.Engine.Gpt6Astra.Tests.csproj -c Release
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 8,
|
||||
"allowRotation": false
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 5,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT11.dxf",
|
||||
"quantity": 6,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 8,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 32,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 16,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,83,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.5983,0.5983,144000.00,144000.00,144000.00,5,120 x 240×5,298,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,4,120 x 240×4,21,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,51,
|
||||
|
@@ -0,0 +1,24 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,24
|
||||
generated-library-1-T,True,24,24,3,5917,0
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,0
|
||||
generated-library-3-Ngon,True,24,24,2,2024,0
|
||||
generated-library-4-Ngon,True,24,24,2,2160,0
|
||||
generated-library-5-Ring,True,24,24,1,3901,3
|
||||
generated-library-6-PipeFlange,True,12,12,2,1932,19
|
||||
generated-ring-inserts,True,16,16,2,2016,2
|
||||
generated-curved-C,True,16,16,3,6051,0
|
||||
generated-narrow-U,True,24,24,3,5925,0
|
||||
generated-stars,True,20,20,1,3901,0
|
||||
generated-seed-00,True,19,19,2,1886,1
|
||||
generated-seed-01,True,22,22,2,2016,1
|
||||
generated-seed-02,True,21,21,2,2150,1
|
||||
generated-seed-03,True,20,20,1,1012,1
|
||||
generated-seed-04,True,24,24,1,1080,1
|
||||
generated-seed-05,True,22,22,2,2050,1
|
||||
generated-seed-06,True,27,27,2,1932,1
|
||||
generated-seed-07,True,19,19,1,1032,1
|
||||
generated-seed-08,True,17,17,1,1100,1
|
||||
generated-seed-09,True,22,22,2,2070,1
|
||||
generated-seed-10,True,20,20,2,1968,1
|
||||
generated-seed-11,True,19,19,2,2100,1
|
||||
|
@@ -0,0 +1,12 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,4,8064,30
|
||||
circles,True,90,90,3,3456,4
|
||||
circles-dense,True,80,80,3,3456,0
|
||||
concave-L,True,60,60,2,5760,0
|
||||
mixed,True,105,105,4,4608,1
|
||||
holes,True,16,16,2,2304,2
|
||||
rectangles,True,96,96,3,3456,1
|
||||
grain,True,60,60,4,4608,0
|
||||
scarce-stock,True,9,9,5,228,0
|
||||
tail,True,17,17,1,600,0
|
||||
plate-cap,True,4,10,1,100,0
|
||||
|
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,259,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,1724,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,133,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,389,
|
||||
|
@@ -0,0 +1,24 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,175
|
||||
generated-library-1-T,True,24,24,2,2016,175
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,120
|
||||
generated-library-3-Ngon,True,24,24,2,2024,536
|
||||
generated-library-4-Ngon,True,24,24,1,1080,93
|
||||
generated-library-5-Ring,True,24,24,1,3901,85
|
||||
generated-library-6-PipeFlange,True,12,12,1,966,46
|
||||
generated-ring-inserts,True,16,16,2,2016,48
|
||||
generated-curved-C,True,16,16,2,2150,1929
|
||||
generated-narrow-U,True,24,24,1,3901,136
|
||||
generated-stars,True,20,20,1,3901,3118
|
||||
generated-seed-00,True,19,19,2,1886,578
|
||||
generated-seed-01,True,22,22,2,2016,403
|
||||
generated-seed-02,True,21,21,2,2150,1447
|
||||
generated-seed-03,True,20,20,1,1012,356
|
||||
generated-seed-04,True,24,24,1,1080,600
|
||||
generated-seed-05,True,22,22,2,2050,1467
|
||||
generated-seed-06,True,27,27,2,1932,1251
|
||||
generated-seed-07,True,19,19,1,1032,724
|
||||
generated-seed-08,True,17,17,1,1100,980
|
||||
generated-seed-09,True,22,22,2,2070,930
|
||||
generated-seed-10,True,20,20,1,984,460
|
||||
generated-seed-11,True,19,19,1,1050,1151
|
||||
|
@@ -0,0 +1,12 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,1,4608,3354
|
||||
circles,True,90,90,3,3456,1021
|
||||
circles-dense,True,80,80,2,2304,742
|
||||
concave-L,True,60,60,3,3456,1055
|
||||
mixed,True,105,105,3,3456,1990
|
||||
holes,True,16,16,1,1152,306
|
||||
rectangles,True,96,96,3,3456,261
|
||||
grain,True,60,60,2,2304,527
|
||||
scarce-stock,True,9,9,5,228,1
|
||||
tail,True,17,17,1,600,4
|
||||
plate-cap,True,10,10,1,400,4
|
||||
|
@@ -0,0 +1,8 @@
|
||||
q=1 offset=0 valid=True:
|
||||
q=1 offset=0.0003 valid=True:
|
||||
q=1 offset=0.05 valid=True:
|
||||
q=1 offset=0.1 valid=True:
|
||||
q=2 offset=0 valid=True:
|
||||
q=2 offset=0.0003 valid=True:
|
||||
q=2 offset=0.05 valid=True:
|
||||
q=2 offset=0.1 valid=True:
|
||||
@@ -0,0 +1,8 @@
|
||||
q=1 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=1 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=1 offset=0.05 valid=True:
|
||||
q=1 offset=0.1 valid=True:
|
||||
q=2 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.05 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.1 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,466,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,2820,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,435,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,2278,
|
||||
|
@@ -0,0 +1,35 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,1,4608,3344
|
||||
circles,True,90,90,3,3456,1008
|
||||
circles-dense,True,80,80,2,2304,748
|
||||
concave-L,True,60,60,3,3456,1082
|
||||
mixed,True,105,105,3,3456,1966
|
||||
holes,True,16,16,1,1152,293
|
||||
rectangles,True,96,96,3,3456,246
|
||||
grain,True,60,60,2,2304,513
|
||||
scarce-stock,True,9,9,5,228,1
|
||||
tail,True,17,17,1,600,4
|
||||
plate-cap,True,10,10,1,400,4
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,170
|
||||
generated-library-1-T,True,24,24,2,2016,172
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,115
|
||||
generated-library-3-Ngon,True,24,24,2,2024,510
|
||||
generated-library-4-Ngon,True,24,24,1,1080,89
|
||||
generated-library-5-Ring,True,24,24,1,3901,918
|
||||
generated-library-6-PipeFlange,True,12,12,1,966,1467
|
||||
generated-ring-inserts,True,16,16,2,2016,4755
|
||||
generated-curved-C,True,16,16,2,2150,1883
|
||||
generated-narrow-U,True,24,24,1,3901,134
|
||||
generated-stars,True,20,20,1,3901,3087
|
||||
generated-seed-00,True,19,19,2,1886,568
|
||||
generated-seed-01,True,22,22,2,2016,398
|
||||
generated-seed-02,True,21,21,2,2150,1427
|
||||
generated-seed-03,True,20,20,1,1012,352
|
||||
generated-seed-04,True,24,24,1,1080,600
|
||||
generated-seed-05,True,22,22,2,2050,1447
|
||||
generated-seed-06,True,27,27,2,1932,1254
|
||||
generated-seed-07,True,19,19,1,1032,705
|
||||
generated-seed-08,True,17,17,1,1100,1008
|
||||
generated-seed-09,True,22,22,2,2070,1025
|
||||
generated-seed-10,True,20,20,1,984,504
|
||||
generated-seed-11,True,19,19,1,1050,1353
|
||||
|
@@ -0,0 +1,347 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra.Tests;
|
||||
|
||||
public class Gpt6AstraNestingEngineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedPartsRespectBoundsSpacingRotationAndIdentity(int quadrant)
|
||||
{
|
||||
var job = new NestJob(new[] {
|
||||
Rectangle("a", 4, 2, 12, RotationPolicy.Fixed(System.Math.PI / 2), 7, -3),
|
||||
Rectangle("b", 3, 3, 8, RotationPolicy.BoundedSweep(-System.Math.PI / 4, System.Math.PI / 2, System.Math.PI / 4))
|
||||
}, new[] { new NestPlateStock("stock", new Size(15, 20), 10, 0.25,
|
||||
new Spacing(1, 2, 3, 1), quadrant) });
|
||||
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions);
|
||||
var again = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements));
|
||||
Assert.Equal(result.Plates.Select(p => p.StockId), again.Plates.Select(p => p.StockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChoosesSmallestSheetWhenDemandFitsBoth()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 1) }, new[] {
|
||||
new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, null, NestJobStopReason.StockExhausted)]
|
||||
[InlineData(null, 1, NestJobStopReason.PlateLimitReached)]
|
||||
public void StopsAtInventoryOrPlateLimit(int? quantity, int? limit, NestJobStopReason reason)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 3) },
|
||||
new[] { new NestPlateStock("s", new Size(2, 2), quantity) }, new NestJobOptions(maxPlates: limit));
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(reason, result.StopReason);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleAndEmptyJobsTerminate()
|
||||
{
|
||||
var part = Rectangle("p", 10, 10, 1);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, new Gpt6AstraNestingEngine().Solve(
|
||||
new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(2, 2)) })).StopReason);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, new Gpt6AstraNestingEngine().Solve(
|
||||
new NestJob(new[] { part }, Array.Empty<NestPlateStock>())).StopReason);
|
||||
Assert.Equal(NestJobStatus.Complete, new Gpt6AstraNestingEngine().Solve(
|
||||
new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>())).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CircleBoundsAreAnalyticAndIncrementalGeometryWorks()
|
||||
{
|
||||
var circle = new Program();
|
||||
circle.MoveTo(13, 10);
|
||||
circle.ArcTo(13, 10, 10, 10, RotationType.CCW);
|
||||
var incremental = new Program(Mode.Incremental);
|
||||
incremental.MoveTo(-5, -5);
|
||||
incremental.LineTo(2, 0);
|
||||
incremental.LineTo(0, 3);
|
||||
incremental.LineTo(-2, 0);
|
||||
incremental.LineTo(0, -3);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("circle", PartGeometrySnapshot.FromProgram(circle), 5),
|
||||
new NestJobPart("incremental", PartGeometrySnapshot.FromProgram(incremental), 5)
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationBeforeAndDuringSolveThrows()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 10) },
|
||||
new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => new Gpt6AstraNestingEngine().Solve(job, token: cts.Token));
|
||||
using var during = new CancellationTokenSource();
|
||||
Assert.Throws<OperationCanceledException>(() => new Gpt6AstraNestingEngine().Solve(job,
|
||||
new CallbackProgress(_ => during.Cancel()), during.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityWinsScarceSpaceAndProgressReflectsCommits()
|
||||
{
|
||||
var low = Rectangle("low", 2, 2, 1);
|
||||
var high = new NestJobPart("high", low.Geometry, 1, priority: 9);
|
||||
var job = new NestJob(new[] { low, high }, new[] { new NestPlateStock("s", new Size(2, 2), 1) });
|
||||
var updates = new List<NestJobProgress>();
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job, new CallbackProgress(updates.Add));
|
||||
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
|
||||
Assert.Equal(NestJobStage.PlateCommitted, updates.Last().Stage);
|
||||
Assert.Equal(1, updates.Last().CommittedParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcaveAndHoledPartsRemainValid()
|
||||
{
|
||||
var l = new Program();
|
||||
l.MoveTo(0, 0); l.LineTo(6, 0); l.LineTo(6, 2);
|
||||
l.LineTo(2, 2); l.LineTo(2, 6); l.LineTo(0, 6); l.LineTo(0, 0);
|
||||
var holed = DrawingJobMapper.ToProgram(Rectangle("template", 8, 8, 1).Geometry);
|
||||
holed.MoveTo(2, 2); holed.LineTo(2, 6); holed.LineTo(6, 6);
|
||||
holed.LineTo(6, 2); holed.LineTo(2, 2);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("concave", PartGeometrySnapshot.FromProgram(l), 7),
|
||||
new NestJobPart("hole", PartGeometrySnapshot.FromProgram(holed), 3)
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeededMixedRectanglesPassBenchmarkValidator()
|
||||
{
|
||||
var random = new Random(7301);
|
||||
for (var trial = 0; trial < 12; trial++)
|
||||
{
|
||||
var parts = Enumerable.Range(0, 6).Select(i => Rectangle($"p{i}",
|
||||
random.Next(1, 9), random.Next(1, 9), random.Next(1, 6),
|
||||
i % 2 == 0 ? RotationPolicy.Automatic : RotationPolicy.Fixed(0))).ToArray();
|
||||
var job = new NestJob(parts, new[] {
|
||||
new NestPlateStock("small", new Size(15, 20), 1, 0.1),
|
||||
new NestPlateStock("large", new Size(25, 30), partSpacing: 0.3)
|
||||
});
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComplementaryTrianglesShareOneEnvelope()
|
||||
{
|
||||
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
|
||||
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 2) },
|
||||
new[] { new NestPlateStock("s", new Size(10, 10), 1) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacesInsertInsideFrameHole()
|
||||
{
|
||||
var frame = DrawingJobMapper.ToProgram(Rectangle("template", 10, 10, 1).Geometry);
|
||||
frame.MoveTo(1, 1); frame.LineTo(1, 9); frame.LineTo(9, 9);
|
||||
frame.LineTo(9, 1); frame.LineTo(1, 1);
|
||||
var job = new NestJob(new[] { new NestJobPart("frame", PartGeometrySnapshot.FromProgram(frame), 1),
|
||||
Rectangle("insert", 7, 7, 1) }, new[] { new NestPlateStock("s", new Size(10, 10), 1, 0.25) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlateLimitSelectsSheetThatCompletesDemand()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 5, 5, 10) }, new[] {
|
||||
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20))
|
||||
}, new NestJobOptions(maxPlates: 1));
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomaticDiagonalIsRetainedWhenItIsTheOnlyStockFit()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("diagonal", 10, 1, 1, RotationPolicy.Automatic) },
|
||||
new[] { new NestPlateStock("s", new Size(8, 8), 1) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseTrianglesRespectPositiveSpacing()
|
||||
{
|
||||
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
|
||||
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 20) },
|
||||
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatchDoesNotLoseEfficientLargeSheetPlans()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 6, 4, 100, RotationPolicy.Automatic) }, new[] {
|
||||
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1),
|
||||
new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactPositiveSpacingRectangleGridStillFits()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 4) },
|
||||
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(0.1)]
|
||||
public void MixedRotatedContoursPassIndependentValidation(double spacing)
|
||||
{
|
||||
for (var trial = 0; trial < 5; trial++)
|
||||
{
|
||||
var t = new Program(); t.MoveTo(0, 0); t.LineTo(4 + trial, 0);
|
||||
t.LineTo(1, 3 + trial); t.LineTo(0, 0);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("t", PartGeometrySnapshot.FromProgram(t), 7),
|
||||
Rectangle("r", 3, 2, 5, RotationPolicy.Fixed(trial * System.Math.PI / 7))
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void CurvedCutoutsAcceptInsertsWhenOnlyOneRingFitsTheStock(int quadrant)
|
||||
{
|
||||
var ring = new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing();
|
||||
var insert = new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing();
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("ring", PartGeometrySnapshot.FromProgram(ring.Program), 1),
|
||||
new NestJobPart("insert", PartGeometrySnapshot.FromProgram(insert.Program), 1)
|
||||
}, new[] { new NestPlateStock("s", new Size(10.8, 10.6), 1, partSpacing: 0.175,
|
||||
edgeSpacing: new Spacing(0.2, 0.3, 0.4, 0.5), quadrant: quadrant) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurvedConcavityInterlocksWithoutFineMeshMinkowskiExplosion()
|
||||
{
|
||||
var c = new Program();
|
||||
c.MoveTo(6, 0); c.ArcTo(0, -6, 0, 0, RotationType.CCW);
|
||||
c.LineTo(0, -4); c.ArcTo(4, 0, 0, 0, RotationType.CW); c.LineTo(6, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("C", PartGeometrySnapshot.FromProgram(c), 8) },
|
||||
new[] { new NestPlateStock("s", new Size(25, 43), 1, 0.25,
|
||||
new Spacing(0.2, 0.3, 0.4, 0.5), 3) });
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => callback(value); }
|
||||
|
||||
private static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0)
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(x, y); p.LineTo(x + w, y); p.LineTo(x + w, y + h);
|
||||
p.LineTo(x, y + h); p.LineTo(x, y);
|
||||
return new(id, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation ?? RotationPolicy.Fixed(0));
|
||||
}
|
||||
|
||||
private static void Validate(NestJob job, NestJobResult result)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (Name: p.Id, Quantity: p.Quantity));
|
||||
var validation = OpenNest.Benchmark.NestValidator.Validate(
|
||||
materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), requirements);
|
||||
OpenNest.Benchmark.NestValidator.ValidateAgainstJob(job, result,
|
||||
job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
Assert.True(validation.Valid, string.Join("; ", validation.Violations));
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var s = sheet.Stock;
|
||||
var left = (s.Quadrant is 1 or 4 ? 0 : -s.Size.Length) + s.EdgeSpacing.Left;
|
||||
var bottom = (s.Quadrant is 1 or 2 ? 0 : -s.Size.Width) + s.EdgeSpacing.Bottom;
|
||||
var right = left + s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right;
|
||||
var top = bottom + s.Size.Width - s.EdgeSpacing.Bottom - s.EdgeSpacing.Top;
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var part = job.Parts.Single(p => p.Id == pose.PartId);
|
||||
Assert.True(part.Rotation.Allows(pose.Rotation));
|
||||
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToArray();
|
||||
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
|
||||
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
|
||||
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
|
||||
Assert.True(b.L >= left - 1e-7 && b.B >= bottom - 1e-7 && b.R <= right + 1e-7 && b.T <= top + 1e-7);
|
||||
}
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
|
||||
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
|
||||
Assert.Equal(placed.Length, fulfillment.Placed);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
}
|
||||
foreach (var usage in result.StockUsage)
|
||||
{
|
||||
var stock = job.Plates.Single(s => s.Id == usage.StockId);
|
||||
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
|
||||
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
|
||||
Assert.True(usage.Remaining is null or >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"sheetSizes": ["120x240", "240x480"],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf", "quantity": 8 },
|
||||
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf", "quantity": 4 }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user