Priority was sorted descending, the reverse of the host (StockLadder and NestJobCandidateComparer treat a lower number as more important), so a priority-9 part beat a priority-0 part for scarce stock. The existing test encoded the inverted rule and now asserts the host's direction. Part geometry filtered only rapids, so scribe/etch moves counted as material - the bug OpenNest fixed in 1b5e1b1. Use SpecialLayers.IsMaterial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
18 KiB
C#
376 lines
18 KiB
C#
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()
|
|
{
|
|
// Lower Priority number ranks higher, as in StockLadderNestingEngine and
|
|
// NestJobCandidateComparer; input order is chosen so it cannot decide the winner.
|
|
var template = Rectangle("template", 2, 2, 1);
|
|
var low = new NestJobPart("low", template.Geometry, 1, priority: 9);
|
|
var high = new NestJobPart("high", template.Geometry, 1, priority: 0);
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void EtchMarksAreLeftOutOfNestingGeometry()
|
|
{
|
|
// A bend tick starts on material and ends 1.0 into a side notch, outside the part but
|
|
// inside its bounding box (the PEP case that crashed nesting before 1b5e1b1). As
|
|
// material it is open geometry leaving the part; as a mark it must be ignored.
|
|
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(NotchedPartWithEtch()), 2,
|
|
rotation: RotationPolicy.Fixed(0)) },
|
|
new[] { new NestPlateStock("s", new Size(10.4, 20.6), 1, partSpacing: 0.2) });
|
|
var result = new Gpt6AstraNestingEngine().Solve(job);
|
|
Validate(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
|
}
|
|
|
|
private static Program NotchedPartWithEtch()
|
|
{
|
|
var p = new Program();
|
|
p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6);
|
|
p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0);
|
|
p.MoveTo(7.5, 5);
|
|
p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
|
return p;
|
|
}
|
|
|
|
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 => SpecialLayers.IsMaterial(e.Layer)).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);
|
|
}
|
|
}
|
|
}
|