feat(qwen38flashnext): add the finished engine
Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run (its commit 7d7fca3): cost-first sheet trials, largest-area-first demand order, and a cached-triangulation exact gate that brought a 219-part production job from timeout to ~106 s. 13/13 tests pass against OpenNest master. README cleaned for publishing: the model-facing template rules are replaced by a one-line independence statement, the production job is described generically instead of by its PEP job/file name (also in a JobSolver comment), results show both sheet pools as re-measured here (the 9-size claim in its report didn't reproduce: it grabs 96x240 and under-fills them), and the stale StockLadder-crash note is gone now that core leaves etch marks out of nesting. Also drops a stale Aurora plugin reference from Opus55's README and lists the engine in the repo README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// These tests target the engine's internal NFP math through its public surface
|
||||
/// (SheetPacker via reflection is overkill; ConvexContour/NfpGeometry are internal,
|
||||
/// so InternalsVisibleTo is required).
|
||||
/// </summary>
|
||||
public class NfpGeometryTests
|
||||
{
|
||||
private static ConvexContour Square(double x0, double y0, double x1, double y1) =>
|
||||
ConvexContour.FromVertices(
|
||||
new[]
|
||||
{
|
||||
new Vector(x0, y0),
|
||||
new Vector(x1, y0),
|
||||
new Vector(x1, y1),
|
||||
new Vector(x0, y1),
|
||||
}
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void MinkowskiOfTwoSquaresIsTheExpectedRectangle()
|
||||
{
|
||||
var a = Square(0, 0, 10, 10);
|
||||
var b = Square(-5, -5, 5, 5); // centered square, side 10
|
||||
|
||||
var sum = NfpGeometry.Minkowski(a, b);
|
||||
|
||||
// [0,10]^2 + [-5,5]^2 = [-5,15]^2
|
||||
Assert.Equal(-5, sum.MinX, 6);
|
||||
Assert.Equal(-5, sum.MinY, 6);
|
||||
Assert.Equal(15, sum.MaxX, 6);
|
||||
Assert.Equal(15, sum.MaxY, 6);
|
||||
|
||||
// Strict containment sanity: center inside, far corner outside.
|
||||
Assert.True(sum.ContainsPoint(0, 0));
|
||||
Assert.True(sum.ContainsPoint(14.9, 14.9));
|
||||
Assert.False(sum.ContainsPoint(20, 20));
|
||||
|
||||
var n = sum.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var ax = sum.X(i);
|
||||
var ay = sum.Y(i);
|
||||
var bx = sum.X((i + 1) % n);
|
||||
var by = sum.Y((i + 1) % n);
|
||||
var cx = sum.X((i + 2) % n);
|
||||
var cy = sum.Y((i + 2) % n);
|
||||
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
|
||||
Assert.True(cross >= -1e-9, $"non-convex (clockwise) turn at vertex {i} of Minkowski result");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinkowskiOfTrianglesIsConvexAndContainsTheSums()
|
||||
{
|
||||
var a = ConvexContour.FromVertices(
|
||||
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(0, 10) }
|
||||
);
|
||||
var b = ConvexContour.FromVertices(
|
||||
new[] { new Vector(0, 0), new Vector(4, 0), new Vector(0, 4) }
|
||||
);
|
||||
|
||||
var sum = NfpGeometry.Minkowski(a, b);
|
||||
|
||||
// Vertex sums must lie on the boundary of the true Minkowski sum.
|
||||
Assert.True(sum.ContainsPoint(1, 1));
|
||||
Assert.True(sum.ContainsPoint(9, 1));
|
||||
Assert.True(sum.ContainsPoint(1, 12));
|
||||
|
||||
var n = sum.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var ax = sum.X(i);
|
||||
var ay = sum.Y(i);
|
||||
var bx = sum.X((i + 1) % n);
|
||||
var by = sum.Y((i + 1) % n);
|
||||
var cx = sum.X((i + 2) % n);
|
||||
var cy = sum.Y((i + 2) % n);
|
||||
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
|
||||
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReflectPreservesCcwWinding()
|
||||
{
|
||||
var a = Square(0, 0, 10, 10);
|
||||
var r = NfpGeometry.Reflect(a);
|
||||
|
||||
Assert.Equal(-10, r.MinX, 6);
|
||||
Assert.Equal(-10, r.MinY, 6);
|
||||
Assert.Equal(0, r.MaxX, 6);
|
||||
Assert.Equal(0, r.MaxY, 6);
|
||||
|
||||
var n = r.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var ax = r.X(i);
|
||||
var ay = r.Y(i);
|
||||
var bx = r.X((i + 1) % n);
|
||||
var by = r.Y((i + 1) % n);
|
||||
var cx = r.X((i + 2) % n);
|
||||
var cy = r.Y((i + 2) % n);
|
||||
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
|
||||
Assert.True(cross >= -1e-9, $"Reflect produced a non-CCW contour at vertex {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NfpOfTwoSquaresIsTheForbiddenAnchorSquare()
|
||||
{
|
||||
// Placed [0,10]^2, candidate [0,10]^2, zero spacing: NFP of forbidden
|
||||
// anchors = placed (+) reflect(candidate) = (-10,10)^2. Anchors strictly
|
||||
// inside it overlap; anchors outside it clear.
|
||||
var placed = Square(0, 0, 10, 10);
|
||||
var candidate = Square(0, 0, 10, 10);
|
||||
var nfp = NfpGeometry.Minkowski(placed, NfpGeometry.Reflect(candidate));
|
||||
|
||||
Assert.Equal(-10, nfp.MinX, 6);
|
||||
Assert.Equal(-10, nfp.MinY, 6);
|
||||
Assert.Equal(10, nfp.MaxX, 6);
|
||||
Assert.Equal(10, nfp.MaxY, 6);
|
||||
|
||||
Assert.True(nfp.ContainsPoint(5, 5)); // overlap
|
||||
Assert.True(nfp.ContainsPoint(-5, -5)); // overlap
|
||||
// Boundary contact counts as forbidden (conservative): the fast-path
|
||||
// certification only accepts anchors CLEAR of the NFP; contact defers to
|
||||
// the exact material gate.
|
||||
Assert.True(nfp.ContainsPoint(10, 0));
|
||||
Assert.False(nfp.ContainsPoint(0, 10.001)); // beyond top, legal
|
||||
|
||||
var n = nfp.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var ax = nfp.X(i);
|
||||
var ay = nfp.Y(i);
|
||||
var bx = nfp.X((i + 1) % n);
|
||||
var by = nfp.Y((i + 1) % n);
|
||||
var cx = nfp.X((i + 2) % n);
|
||||
var cy = nfp.Y((i + 2) % n);
|
||||
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
|
||||
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<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" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.csproj" />
|
||||
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user