BuildResult set PlateIndex to the stock index, so every sheet cut from the same stock shared one index; OpenNest.Api maps plates by it. 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>
203 lines
7.8 KiB
C#
203 lines
7.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using OpenNest.Benchmark;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
|
|
|
|
/// <summary>
|
|
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
|
|
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
|
|
/// Solve() is implemented; add engine-specific tests alongside them.
|
|
/// </summary>
|
|
public class Qwen38FlashNextNestingEngineTests
|
|
{
|
|
[Fact]
|
|
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
|
{
|
|
var engine = Activator.CreateInstance(typeof(Qwen38FlashNextNestingEngine));
|
|
Assert.IsAssignableFrom<INestingEngine>(engine);
|
|
}
|
|
|
|
[Fact]
|
|
public void RectanglesFitOnOneSheetWithSpacing()
|
|
{
|
|
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
Assert.Single(result.Plates);
|
|
Assert.Equal(12, result.Plates[0].Placements.Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
[InlineData(3)]
|
|
[InlineData(4)]
|
|
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
|
{
|
|
var job = Job(
|
|
new[]
|
|
{
|
|
Part("disc", Disc(3), 10),
|
|
Part("ell", LShape(12, 8, 4), 10),
|
|
Part("tri", Triangle(9, 6), 10),
|
|
},
|
|
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
|
);
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void RotatedConcavePartsKeepSpacingAtFixedAngles()
|
|
{
|
|
// Regression: the per-orientation spacing inflation must live in the rotated
|
|
// frame. L-shapes pinned to 90/270 degrees exercise exactly the orientations
|
|
// where an unrotated inflation misrepresents the material and lets parts
|
|
// rest closer than the spacing.
|
|
var l = Part(
|
|
"l90",
|
|
LShape(12, 8, 4),
|
|
8,
|
|
RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)
|
|
);
|
|
var job = Job(new[] { l }, new[] { Stock("sheet", 40, 60, spacing: 0.5) });
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void OverflowSpillsOntoAdditionalSheets()
|
|
{
|
|
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
Assert.True(result.Plates.Count > 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void PartTooBigForAnySheetIsReportedUnplaced()
|
|
{
|
|
var job = Job(
|
|
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
|
|
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
|
|
);
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
|
|
Assert.Equal(1, huge.Unplaced);
|
|
}
|
|
|
|
[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 etched = Polyline((0, 0), (10, 0), (10, 4), (8, 4), (8, 6), (10, 6), (10, 10), (0, 10));
|
|
etched.Codes.Add(new RapidMove(7.5, 5));
|
|
etched.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
|
var job = Job(new[] { Part("part", etched, 2, RotationPolicy.Fixed(0)) }, new[] { Stock("sheet", 10.4, 20.6, spacing: 0.2) });
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
AssertValid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateIndicesRunInCommitOrder()
|
|
{
|
|
// Every sheet comes from the same stock (index 0), so the stock index must not leak
|
|
// into PlateIndex: the host and OpenNest.Api treat it as the sheet's position.
|
|
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
|
|
|
|
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
|
|
|
Assert.True(result.Plates.Count > 1);
|
|
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
|
|
}
|
|
|
|
// ---- helpers -------------------------------------------------------------------------
|
|
|
|
private static void AssertValid(NestJob job, NestJobResult result)
|
|
{
|
|
var materialized = NestResultMaterializer.Materialize(job, result);
|
|
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
|
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
|
p => materialized.DrawingsByPartId[p.Id],
|
|
p => (p.Id, p.Quantity),
|
|
ReferenceEqualityComparer.Instance
|
|
);
|
|
var validation = NestValidator.Validate(runs, requirements);
|
|
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
|
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
|
|
|
foreach (var f in result.Fulfillment)
|
|
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
|
}
|
|
|
|
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
|
new(parts, stock, options);
|
|
|
|
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
|
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
|
|
|
/// <param name="width">Y extent.</param>
|
|
/// <param name="length">X extent.</param>
|
|
private static NestPlateStock Stock(
|
|
string id,
|
|
double width,
|
|
double length,
|
|
double spacing = 0,
|
|
Spacing edge = default,
|
|
int quadrant = 1,
|
|
int? quantity = null
|
|
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
|
|
|
private static Program Polyline(params (double X, double Y)[] points)
|
|
{
|
|
var program = new Program();
|
|
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
|
foreach (var (x, y) in points.Skip(1))
|
|
program.Codes.Add(new LinearMove(x, y));
|
|
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
|
return program;
|
|
}
|
|
|
|
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
|
|
|
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
|
|
|
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
|
|
|
private static Program Disc(double r)
|
|
{
|
|
var program = new Program();
|
|
program.Codes.Add(new RapidMove(r, 0));
|
|
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
|
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
|
return program;
|
|
}
|
|
}
|