Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cc2ee810 | ||
|
|
0cdef009f8 | ||
|
|
0308a862b8 | ||
|
|
d0c6af783b | ||
|
|
c691381f30 | ||
|
|
54e8a0461b | ||
|
|
1579fa6810 | ||
|
|
ffa3590b46 | ||
|
|
f973c1c15c | ||
|
|
90f07603e2 | ||
|
|
ffd0187fbb |
+2
-2
@@ -27,7 +27,7 @@ if (-not (Test-Path (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchm
|
||||
}
|
||||
$OpenNestRoot = (Resolve-Path $OpenNestRoot).Path
|
||||
|
||||
dotnet build (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchmark.csproj') -c $Configuration
|
||||
dotnet build (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchmark.csproj') -c $Configuration -m:1 -nr:false
|
||||
if ($LASTEXITCODE -ne 0) { throw 'OpenNest.Benchmark build failed.' }
|
||||
|
||||
$deployDir = Join-Path $OpenNestRoot "OpenNest.Benchmark/bin/$Configuration/net8.0/Engines"
|
||||
@@ -38,7 +38,7 @@ $projects = Get-ChildItem $PSScriptRoot -Directory -Filter 'OpenNest.Engine.*' |
|
||||
|
||||
foreach ($dir in $projects) {
|
||||
$csproj = Join-Path $dir.FullName "$($dir.Name).csproj"
|
||||
dotnet build $csproj -c $Configuration "-p:OpenNestRoot=$OpenNestRoot/"
|
||||
dotnet build $csproj -c $Configuration "-p:OpenNestRoot=$OpenNestRoot/" -m:1 -nr:false
|
||||
if ($LASTEXITCODE -ne 0) { throw "$($dir.Name) build failed." }
|
||||
|
||||
$dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Testing;
|
||||
|
||||
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
|
||||
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
|
||||
{
|
||||
[Fact]
|
||||
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(Activator.CreateInstance(typeof(TEngine)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void ContractQuadrants(int quadrant)
|
||||
{
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
|
||||
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOverflowIndicesAndProgress()
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
|
||||
var commits = new List<NestJobProgress>();
|
||||
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(3, result.Plates.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex));
|
||||
Assert.Equal(3, commits.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts));
|
||||
Assert.All(result.StockUsage, s => Assert.Null(s.Remaining));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOversize()
|
||||
{
|
||||
var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractLowerNumberPriorityWins()
|
||||
{
|
||||
var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
|
||||
[Stock("s", 10, 10, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractEtchOutsideSheetIsIgnored()
|
||||
{
|
||||
var etched = NotchedPartWithEtch();
|
||||
etched.Codes.Add(new RapidMove(5, 5));
|
||||
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
|
||||
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractDeterminism()
|
||||
{
|
||||
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
|
||||
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
|
||||
var engine = new TEngine();
|
||||
var job = Build();
|
||||
var first = engine.Solve(job);
|
||||
var second = engine.Solve(job);
|
||||
var third = new TEngine().Solve(Build());
|
||||
foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(Describe(first), Describe(second));
|
||||
Assert.Equal(Describe(first), Describe(third));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, token: cancellation.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationDuringSolveThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
|
||||
var progress = new Capture(p =>
|
||||
{
|
||||
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
|
||||
});
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, progress, cancellation.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ContractStockAndPlateLimits(bool plateLimit)
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)],
|
||||
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
|
||||
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason);
|
||||
}
|
||||
|
||||
private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result);
|
||||
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => action(value); }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Testing;
|
||||
|
||||
public static class JobBuilder
|
||||
{
|
||||
public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
public static NestJobPart Part(string id, Program program, int quantity,
|
||||
RotationPolicy? rotation = null, int priority = 0) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
public 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);
|
||||
|
||||
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
||||
Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)),
|
||||
count, rotation ?? RotationPolicy.Fixed(0));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Testing;
|
||||
|
||||
public static class LayoutAssert
|
||||
{
|
||||
public static void Valid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
|
||||
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var s = sheet.Stock;
|
||||
var work = s.WorkArea;
|
||||
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 >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7
|
||||
&& b.R <= work.Right + 1e-7 && b.T <= work.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,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Engine.Testing;
|
||||
|
||||
public static class Shapes
|
||||
{
|
||||
public 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;
|
||||
}
|
||||
|
||||
public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
public static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||
public static Program Obround(double length, double width)
|
||||
{
|
||||
var r = width / 2;
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
public 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;
|
||||
}
|
||||
|
||||
public static Program Ring(double outerDiameter, double innerDiameter) =>
|
||||
new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program;
|
||||
}
|
||||
+19
-12
@@ -7,8 +7,8 @@
|
||||
placeholder in file names and contents. The new engine builds against OpenNest via
|
||||
Directory.Build.props, which must sit in <Destination> or a parent folder.
|
||||
|
||||
With -IncludeBuildFiles, Directory.Build.props/.targets are copied into <Destination>
|
||||
too, so the engine can live outside this repo, e.g. in an Engines/ folder inside an
|
||||
With -IncludeBuildFiles, Directory.Build.props/.targets and the shared Engine.Testing
|
||||
source kit are copied into <Destination>, so the engine can live outside this repo, e.g. in an Engines/ folder inside an
|
||||
OpenNest checkout (the props detect that layout on their own).
|
||||
|
||||
.EXAMPLE
|
||||
@@ -29,21 +29,28 @@ $target = Join-Path $Destination "OpenNest.Engine.$Name"
|
||||
if (Test-Path $target) { throw "'$target' already exists." }
|
||||
|
||||
New-Item -ItemType Directory -Force $Destination | Out-Null
|
||||
Copy-Item $template $target -Recurse
|
||||
|
||||
Get-ChildItem $target -Recurse -File | ForEach-Object {
|
||||
$text = [IO.File]::ReadAllText($_.FullName)
|
||||
[IO.File]::WriteAllText($_.FullName, $text.Replace('__NAME__', $Name))
|
||||
}
|
||||
# Deepest paths first so renaming a folder never invalidates a pending child path.
|
||||
Get-ChildItem $target -Recurse | Where-Object Name -like '*__NAME__*' |
|
||||
Sort-Object { $_.FullName.Length } -Descending |
|
||||
ForEach-Object { Rename-Item $_.FullName $_.Name.Replace('__NAME__', $Name) }
|
||||
# A built template contains binary/obj files. Copy only source files, never rewrite binaries.
|
||||
$template = (Resolve-Path $template).Path
|
||||
Get-ChildItem $template -Recurse -File |
|
||||
Where-Object { $_.FullName.Substring($template.Length) -notmatch '[\\/](bin|obj)[\\/]' } |
|
||||
ForEach-Object {
|
||||
$relative = $_.FullName.Substring($template.Length + 1).Replace('__NAME__', $Name)
|
||||
$output = Join-Path $target $relative
|
||||
New-Item -ItemType Directory -Force (Split-Path $output -Parent) | Out-Null
|
||||
$text = [IO.File]::ReadAllText($_.FullName)
|
||||
[IO.File]::WriteAllText($output, $text.Replace('__NAME__', $Name))
|
||||
}
|
||||
|
||||
if ($IncludeBuildFiles) {
|
||||
foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') {
|
||||
Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force
|
||||
}
|
||||
# The acceptance tests reference the shared, read-only kit beside the engine.
|
||||
$kitTarget = Join-Path $Destination 'Engine.Testing'
|
||||
New-Item -ItemType Directory -Force $kitTarget | Out-Null
|
||||
Get-ChildItem (Join-Path $PSScriptRoot 'Engine.Testing') -File |
|
||||
Where-Object { $_.Extension -in '.cs', '.csproj' } |
|
||||
Copy-Item -Destination $kitTarget -Force
|
||||
}
|
||||
|
||||
Write-Host "Created $target"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Gpt6Astra;
|
||||
@@ -47,7 +48,9 @@ internal sealed class ContactGeometry
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
var delta = spacing + stationary.ContactError + moving.ContactError
|
||||
+ (stationary.Curved || moving.Curved ? 0.003 : spacing > 0 ? 0.0003 : 0);
|
||||
+ (stationary.Curved || moving.Curved
|
||||
? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline)
|
||||
: spacing > 0 ? NestTolerances.SafeClearanceMargin(0) : 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.
|
||||
|
||||
@@ -11,21 +11,22 @@ internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape
|
||||
/// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
|
||||
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token)
|
||||
{
|
||||
private static readonly double GridUnit = M.Pow(10, -NestTolerances.ClipperPrecision);
|
||||
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;
|
||||
validationOriginX = stock.WorkArea.Left;
|
||||
validationOriginY = stock.WorkArea.Bottom;
|
||||
var width = stock.WorkArea.Length;
|
||||
var height = stock.WorkArea.Width;
|
||||
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)
|
||||
.OrderBy(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();
|
||||
@@ -112,10 +113,10 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
|
||||
// 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;
|
||||
var l = hole.Min(p => p.x) + other.X + spacing + (4 * GridUnit);
|
||||
var b = hole.Min(p => p.y) + other.Y + spacing + (4 * GridUnit);
|
||||
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - (4 * GridUnit);
|
||||
var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - (4 * GridUnit);
|
||||
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.
|
||||
@@ -144,8 +145,8 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
|
||||
// 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) })
|
||||
((3 * GridUnit), 0), (0, (3 * GridUnit)), ((3 * GridUnit), (3 * GridUnit)), (-(3 * GridUnit), 0),
|
||||
(0, -(3 * GridUnit)), (-(3 * GridUnit), (3 * GridUnit)), ((3 * GridUnit), -(3 * GridUnit)), (-(3 * GridUnit), -(3 * GridUnit)) })
|
||||
{
|
||||
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;
|
||||
@@ -202,10 +203,9 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
|
||||
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);
|
||||
var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline) : NestTolerances.SafeClearanceMargin(0));
|
||||
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 ||
|
||||
@@ -223,46 +223,24 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
|
||||
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 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))
|
||||
{
|
||||
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;
|
||||
NestJobPlacement Pose(PackedShape p) => new("check", 0,
|
||||
validationOriginX + p.X - p.Variant.OriginX,
|
||||
validationOriginY + p.Y - p.Variant.OriginY, p.Variant.Angle);
|
||||
// Equal-left ties follow commit order, just as the full-layout check does.
|
||||
collides = !NestLayoutCheck.Clears(other.Variant.Geometry, Pose(other),
|
||||
candidate.Variant.Geometry, Pose(candidate), spacing);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -14,15 +14,14 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
|
||||
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();
|
||||
s.Fits(v.Width, v.Height))).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 priorities = job.Parts.Select(p => p.Priority).Distinct().Order().ToArray();
|
||||
var evaluated = 0;
|
||||
var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray();
|
||||
while (frontier.Count > 0)
|
||||
@@ -56,7 +55,9 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
|
||||
evaluated++;
|
||||
}
|
||||
if (trial.Shapes.Count == 0) continue;
|
||||
var sheetCost = job.Plates[s].Size.Length * job.Plates[s].Size.Width;
|
||||
var sheetCost = NestJobCost.NetSheetArea(job,
|
||||
new NestJobPlateResult(0, job.Plates[s], Poses(trial).Select(p =>
|
||||
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))));
|
||||
for (var p = 0; p < parts.Length; p++)
|
||||
{
|
||||
var delivered = trial.Counts[p] - state.Counts[p];
|
||||
@@ -65,7 +66,7 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
|
||||
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);
|
||||
state.Cost + sheetCost);
|
||||
if (BetterFulfillment(next, best)) best = next;
|
||||
if (IsComplete(next))
|
||||
{
|
||||
@@ -97,29 +98,26 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
|
||||
}
|
||||
}
|
||||
var selected = complete ?? best;
|
||||
var counts = new int[parts.Length];
|
||||
var plates = new List<NestJobPlateResult>();
|
||||
var builder = new NestJobResultBuilder(job, progress);
|
||||
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()));
|
||||
builder.AddSheet(job.Plates[sheet.StockIndex], Poses(sheet));
|
||||
}
|
||||
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])));
|
||||
return builder.Build(reason);
|
||||
|
||||
IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses(SheetTrial sheet)
|
||||
{
|
||||
var work = job.Plates[sheet.StockIndex].WorkArea;
|
||||
return sheet.Shapes.Select(p => (job.Parts[p.Variant.Part].Id,
|
||||
work.Left + p.X - p.Variant.OriginX, work.Bottom + p.Y - p.Variant.OriginY,
|
||||
p.Variant.Angle));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
@@ -25,24 +23,11 @@ internal sealed class ShapeVariant
|
||||
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 required JobPartGeometry Geometry { 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)
|
||||
@@ -50,7 +35,7 @@ internal sealed class ShapeVariant
|
||||
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);
|
||||
var delta = spacing + (Curved ? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline) : spacing > 0 ? NestTolerances.SafeClearanceMargin(0) : 0);
|
||||
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
|
||||
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
|
||||
}
|
||||
@@ -77,17 +62,19 @@ internal static class GeometryPreparation
|
||||
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 geometry = JobPartGeometry.Read(part.Geometry);
|
||||
var baseProfile = geometry.Profile;
|
||||
var closed = new[] { geometry.Perimeter }.Concat(geometry.Cutouts)
|
||||
.SelectMany(shape => shape.Entities).ToList();
|
||||
var area = geometry.MaterialArea;
|
||||
var variants = new List<ShapeVariant>();
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var angle in Angles(part.Rotation, baseProfile))
|
||||
var angles = Angles(part.Rotation, baseProfile).ToArray();
|
||||
// The host symmetry primitive compares perimeters only. Keep full-material
|
||||
// signatures for holed parts, whose cutouts can break perimeter symmetry.
|
||||
var distinct = baseProfile.Cutouts.Count == 0
|
||||
? RotationCandidates.DistinctOutlines(baseProfile.Perimeter, angles) : angles;
|
||||
foreach (var angle in distinct)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList();
|
||||
@@ -97,15 +84,14 @@ internal static class GeometryPreparation
|
||||
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);
|
||||
var material = ClipperBridge.ToRegion(profile, NestTolerances.ValidationOutline, 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 outline = ClipperBridge.Flatten(profile.Perimeter, NestTolerances.ValidationOutline, 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
|
||||
@@ -118,7 +104,7 @@ internal static class GeometryPreparation
|
||||
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 });
|
||||
Hull = hull, Convex = convex, Geometry = geometry });
|
||||
}
|
||||
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
|
||||
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
|
||||
@@ -130,8 +116,7 @@ internal static class GeometryPreparation
|
||||
// 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;
|
||||
bool Fits(ShapeVariant v) => stock.Fits(v.Width, v.Height);
|
||||
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
|
||||
}
|
||||
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
|
||||
@@ -151,7 +136,7 @@ internal static class GeometryPreparation
|
||||
|
||||
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
|
||||
{
|
||||
var values = new List<double>();
|
||||
var values = new List<double>(RotationCandidates.ForShape(policy, profile.Perimeter));
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
// All half-turns matter for asymmetric parts, unlike envelope-only packing.
|
||||
@@ -163,19 +148,7 @@ internal static class GeometryPreparation
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
values = policy.EnumerateAngles().ToList();
|
||||
var seen = new HashSet<long>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
|
||||
@@ -11,17 +11,16 @@ sheet's usable translation rectangle exposes contact positions where another par
|
||||
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
|
||||
1. Validate immutable job input. Read owned analytic material with `JobPartGeometry.Read`. 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
|
||||
Automatic angles extend `RotationCandidates.ForShape` with 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.
|
||||
up to 720 base samples through `RotationPolicy.EnumerateAngles`, including permitted half-turn equivalents.
|
||||
3. Process high-priority parts first (a lower `Priority` number ranks higher, as in the host),
|
||||
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
|
||||
@@ -40,7 +39,7 @@ concave interlocking, and insertion into straight-edged and curved holes.
|
||||
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.
|
||||
7. Select a complete plan with lowest `NestJobCost.NetSheetArea` including salvage, 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.
|
||||
@@ -53,10 +52,11 @@ corrects curved-hole validation; the placement algorithm remains entirely in Gpt
|
||||
## 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
|
||||
at `NestTolerances.ValidationOutline`. Contact generation and material halos both use
|
||||
`SafeClearanceMargin` so proposed contacts pass the same clearance gate. Axis-aligned
|
||||
rectangle contacts preserve exact requested spacing. `NestLayoutCheck.Clears` replaces
|
||||
the copied validator construction and collision sequence, with world-pose caching.
|
||||
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.
|
||||
@@ -123,3 +123,15 @@ non-cardinal rotations, hole insertion, automatic diagonal-only stock fits, all
|
||||
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.
|
||||
|
||||
## Shared services migration
|
||||
|
||||
Stock bounds and fit checks use the host stock primitives; committed results and progress
|
||||
use `NestJobResultBuilder`. The test project shares `Engine.Testing` and no longer links
|
||||
benchmark source. The synthetic console also validates through `NestLayoutCheck` and
|
||||
reports `NestJobCost.Evaluate`. Automatic 15-degree/edge sampling, beam search, contact
|
||||
placement, exact rectangle handling, and full-material symmetry signatures for holed
|
||||
parts remain engine-owned. The host symmetry helper compares only perimeters.
|
||||
|
||||
The five salvage benchmarks stayed valid and complete. Cost fell from 5574.07 to
|
||||
5470.07 overall (no job worsened at report precision); see [PR 5 results](../MIGRATION-PR5.md).
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
<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>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -30,10 +29,8 @@ if (args.Contains("--diagnose-ring"))
|
||||
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)}");
|
||||
var violations = NestLayoutCheck.Violations(j, result);
|
||||
Console.WriteLine($"q={quadrant} offset={offset} valid={violations.Count == 0}: {string.Join(';', violations)}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -73,7 +70,7 @@ Add("tail", new[] { Part("rect", Rect(6, 4), 17) }, new[] {
|
||||
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");
|
||||
Console.WriteLine("case,valid,placed,requested,sheets,cost,milliseconds");
|
||||
foreach (var (name, job) in cases)
|
||||
{
|
||||
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
|
||||
@@ -82,13 +79,10 @@ foreach (var (name, job) in cases)
|
||||
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}");
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
if (violations.Count != 0) Environment.ExitCode = 1;
|
||||
Console.WriteLine($"{name},{violations.Count == 0},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{NestJobCost.Evaluate(job, result)},{sw.ElapsedMilliseconds}");
|
||||
foreach (var violation in violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
|
||||
}
|
||||
catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
The driver validates materialized output with `NestLayoutCheck.Violations`, 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`.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
@@ -23,7 +26,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
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);
|
||||
LayoutAssert.Valid(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));
|
||||
@@ -37,7 +40,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -50,7 +53,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(reason, result.StopReason);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -83,7 +86,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
}, 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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,8 +105,11 @@ public class Gpt6AstraNestingEngineTests
|
||||
[Fact]
|
||||
public void PriorityWinsScarceSpaceAndProgressReflectsCommits()
|
||||
{
|
||||
var low = Rectangle("low", 2, 2, 1);
|
||||
var high = new NestJobPart("high", low.Geometry, 1, priority: 9);
|
||||
// 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));
|
||||
@@ -127,7 +133,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
}, 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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -145,7 +151,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
});
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +165,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,7 +179,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,7 +191,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -195,7 +201,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
new[] { new NestPlateStock("s", new Size(8, 8), 1) });
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -207,7 +213,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -219,7 +225,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -229,7 +235,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -247,7 +253,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
}, 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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +274,7 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -284,64 +290,32 @@ public class Gpt6AstraNestingEngineTests
|
||||
var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
LayoutAssert.Valid(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);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public sealed class Gpt6AstraContractTests : EngineContractTests<Gpt6AstraNestingEngine> { }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -63,25 +63,9 @@ internal sealed class FrontierPacker
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = WorkArea(stock);
|
||||
work = stock.WorkArea;
|
||||
}
|
||||
|
||||
public static Box WorkArea(NestPlateStock stock)
|
||||
{
|
||||
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||
return new Box(
|
||||
left + stock.EdgeSpacing.Left,
|
||||
bottom + stock.EdgeSpacing.Bottom,
|
||||
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
|
||||
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>True when the orientation's bounds fit the work area at all (Box.Length is the X extent).</summary>
|
||||
public static bool Fits(Orientation o, Box work) =>
|
||||
o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9;
|
||||
|
||||
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
@@ -91,7 +75,7 @@ internal sealed class FrontierPacker
|
||||
if (left[type.Index] <= 0)
|
||||
continue;
|
||||
foreach (var o in type.Orientations)
|
||||
if (Fits(o, work))
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
}
|
||||
|
||||
@@ -140,17 +124,21 @@ internal sealed class FrontierPacker
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
|
||||
foreach (var region in states)
|
||||
{
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var area = types[region.Orientation.TypeIndex].Area;
|
||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
||||
if (priority > bestPriority) continue;
|
||||
var fills = advance <= Tie;
|
||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||
|
||||
var better = bestRegion == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
fills == bestFills
|
||||
@@ -165,6 +153,7 @@ internal sealed class FrontierPacker
|
||||
if (!better)
|
||||
continue;
|
||||
bestRegion = region;
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace OpenNest.Engine.Opus55;
|
||||
internal sealed class NoFitCache
|
||||
{
|
||||
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
|
||||
public const int Precision = 4;
|
||||
public const int Precision = NestTolerances.ClipperPrecision;
|
||||
|
||||
private readonly double halfClearance;
|
||||
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
|
||||
@@ -43,7 +44,10 @@ internal sealed class NoFitCache
|
||||
// footprint is a superset of "every point within the clearance of the outline".
|
||||
var inflated = Clipper.InflatePaths(
|
||||
new PathsD { o.Outline },
|
||||
halfClearance + o.Tolerance,
|
||||
// Four additional grid units cover this engine's repeated footprint/NFP
|
||||
// Boolean operations. Keep its established contact points and packing quality.
|
||||
halfClearance + NestTolerances.SafeClearanceMargin(o.Tolerance) / 2
|
||||
+ 4 * System.Math.Pow(10, -Precision),
|
||||
JoinType.Miter,
|
||||
EndType.Polygon,
|
||||
2.0,
|
||||
|
||||
@@ -19,13 +19,6 @@ namespace OpenNest.Engine.Opus55;
|
||||
/// </summary>
|
||||
public sealed class Opus55NestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Extra clearance beyond the stock's part spacing, in job units. Validators polygonize arcs
|
||||
/// circumscribed at 0.01 per side, so two tangent true arcs can read as up to 0.02 closer
|
||||
/// than they are; the rest absorbs Clipper's 1e-4 grid and inner-fit clamping.
|
||||
/// </summary>
|
||||
internal const double ClearanceMargin = 0.022;
|
||||
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
{
|
||||
@@ -50,6 +43,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var types = PartCatalog.Build(job);
|
||||
var solver = new Solver(job, types, progress, token);
|
||||
|
||||
@@ -59,7 +53,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
{
|
||||
var placeable = job.Plates.Any(stock =>
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock)))
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
}
|
||||
@@ -95,7 +89,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
public WorkCounter Work { get; } = new();
|
||||
|
||||
private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea);
|
||||
private double Penalty => NestJobCost.UnplacedPartPenalty(job);
|
||||
|
||||
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
||||
{
|
||||
@@ -122,7 +116,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||
var tailNet = tail.Sum(s => SheetEconomics.NetArea(job.Options, s));
|
||||
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
||||
var tailDemand = new int[types.Count];
|
||||
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||
tailDemand[part.Orientation.TypeIndex]++;
|
||||
@@ -154,7 +148,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
private NoFitCache CacheFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing) + ClearanceMargin;
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!caches.TryGetValue(clearance, out var cache))
|
||||
caches[clearance] = cache = new NoFitCache(clearance);
|
||||
return cache;
|
||||
@@ -205,7 +199,7 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, SheetEconomics.NetArea(job.Options, fill)));
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
@@ -239,36 +233,23 @@ public sealed class Opus55NestingEngine : INestingEngine
|
||||
|
||||
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
||||
|
||||
private static NestJobResult BuildResult(
|
||||
NestJob job,
|
||||
IReadOnlyList<PartType> types,
|
||||
Plan plan,
|
||||
IProgress<NestJobProgress>? progress
|
||||
)
|
||||
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
|
||||
Plan plan, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
var placed = new int[types.Count];
|
||||
var plates = new List<NestJobPlateResult>(plan.Sheets.Count);
|
||||
var committedParts = 0;
|
||||
var builder = new NestJobResultBuilder(job, progress);
|
||||
foreach (var sheet in plan.Sheets)
|
||||
{
|
||||
var placements = sheet.Parts.Select(p =>
|
||||
{
|
||||
var type = types[p.Orientation.TypeIndex];
|
||||
return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation);
|
||||
});
|
||||
plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList()));
|
||||
committedParts += sheet.Parts.Count;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts));
|
||||
}
|
||||
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
||||
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
return builder.Build(plan.Reason);
|
||||
}
|
||||
|
||||
var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index]));
|
||||
var usage = job.Plates.Select(stock =>
|
||||
{
|
||||
var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock));
|
||||
return new StockUsage(stock.Id, count, stock.Quantity - count);
|
||||
});
|
||||
var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete;
|
||||
return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList());
|
||||
private static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
||||
}
|
||||
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
@@ -56,8 +54,6 @@ internal static class PartCatalog
|
||||
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
||||
private const int MaxOrientations = 8;
|
||||
|
||||
private const double TwoPi = System.Math.PI * 2;
|
||||
|
||||
public static IReadOnlyList<PartType> Build(NestJob job)
|
||||
{
|
||||
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
||||
@@ -83,21 +79,15 @@ internal static class PartCatalog
|
||||
continue;
|
||||
}
|
||||
|
||||
var angles = CandidateAngles(part.Rotation, perimeter, perType);
|
||||
var angles = RotationCandidates.DistinctOutlines(perimeter,
|
||||
CandidateAngles(part.Rotation, perimeter, perType));
|
||||
var tolerance = ChooseTolerance(perimeter);
|
||||
var orientations = new List<Orientation>();
|
||||
var signatures = new List<string>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var outline = Polygonize(perimeter, angle, tolerance);
|
||||
if (outline.Count < 3)
|
||||
continue;
|
||||
// Point-symmetric parts (rectangles, discs...) look identical at several angles;
|
||||
// evaluating duplicates only costs time.
|
||||
var signature = Signature(outline);
|
||||
if (signatures.Contains(signature))
|
||||
continue;
|
||||
signatures.Add(signature);
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||
}
|
||||
|
||||
@@ -107,17 +97,8 @@ internal static class PartCatalog
|
||||
return types;
|
||||
}
|
||||
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry)
|
||||
{
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
|
||||
.ToList();
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
var profile = new ShapeProfile(entities);
|
||||
return profile.Perimeter is { } perimeter && perimeter.Area() > 1e-9 ? perimeter : null;
|
||||
}
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||
|
||||
/// <summary>
|
||||
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
||||
@@ -170,105 +151,6 @@ internal static class PartCatalog
|
||||
};
|
||||
}
|
||||
|
||||
private static string Signature(PathD outline)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(outline);
|
||||
var points = outline
|
||||
.Select(p => (System.Math.Round(p.x - bounds.left, 5), System.Math.Round(p.y - bounds.top, 5)))
|
||||
.OrderBy(p => p.Item1)
|
||||
.ThenBy(p => p.Item2)
|
||||
.Select(p => $"{p.Item1:R},{p.Item2:R}");
|
||||
return string.Join(";", points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotations to try, all satisfying the part's policy. Automatic parts get the four
|
||||
/// right angles plus the two orientations that align their minimum-area bounding
|
||||
/// rectangle with the sheet axes.
|
||||
/// </summary>
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit)
|
||||
{
|
||||
var raw = new List<double>();
|
||||
switch (policy.Kind)
|
||||
{
|
||||
case RotationPolicyKind.Fixed:
|
||||
raw.Add(policy.Start);
|
||||
if (policy.Allow180Equivalent)
|
||||
raw.Add(policy.Start + System.Math.PI);
|
||||
break;
|
||||
|
||||
case RotationPolicyKind.BoundedSweep:
|
||||
{
|
||||
var steps = (int)System.Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
|
||||
var samples = System.Math.Min(steps + 1, policy.Allow180Equivalent ? System.Math.Max(1, limit / 2) : limit);
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var k = samples == 1 ? 0 : (int)System.Math.Round(i * (double)steps / (samples - 1));
|
||||
raw.Add(policy.Start + k * policy.Step);
|
||||
if (policy.Allow180Equivalent)
|
||||
raw.Add(policy.Start + k * policy.Step + System.Math.PI);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
var rightAngles = new[] { 0, System.Math.PI / 2, System.Math.PI, System.Math.PI * 1.5 };
|
||||
var aligned = AlignedAngle(perimeter);
|
||||
raw.Add(0);
|
||||
raw.Add(System.Math.PI / 2);
|
||||
if (aligned is double a)
|
||||
{
|
||||
raw.Add(Normalize(a));
|
||||
raw.Add(Normalize(a + System.Math.PI / 2));
|
||||
}
|
||||
raw.Add(System.Math.PI);
|
||||
raw.Add(System.Math.PI * 1.5);
|
||||
if (aligned is double b)
|
||||
{
|
||||
raw.Add(Normalize(b + System.Math.PI));
|
||||
raw.Add(Normalize(b + System.Math.PI * 1.5));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<double>();
|
||||
foreach (var angle in raw)
|
||||
{
|
||||
if (!policy.Allows(angle))
|
||||
continue;
|
||||
if (result.Any(existing => SameTurn(existing, angle)))
|
||||
continue;
|
||||
result.Add(angle);
|
||||
if (result.Count >= limit)
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double? AlignedAngle(Shape perimeter)
|
||||
{
|
||||
var polygon = perimeter.ToPolygonWithTolerance(ChordTolerance * 5);
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return null;
|
||||
var mbr = RotatingCalipers.MinimumBoundingRectangle(polygon.Vertices);
|
||||
var angle = Normalize(-mbr.Angle) % (System.Math.PI / 2);
|
||||
// Already axis-aligned (within ~0.05°): the right angles cover it.
|
||||
if (angle < 1e-3 || System.Math.PI / 2 - angle < 1e-3)
|
||||
return null;
|
||||
return angle;
|
||||
}
|
||||
|
||||
private static double Normalize(double angle)
|
||||
{
|
||||
var value = angle % TwoPi;
|
||||
return value < 0 ? value + TwoPi : value;
|
||||
}
|
||||
|
||||
private static bool SameTurn(double a, double b)
|
||||
{
|
||||
var delta = System.Math.Abs(Normalize(a - b));
|
||||
return delta < 1e-9 || TwoPi - delta < 1e-9;
|
||||
}
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
|
||||
RotationCandidates.ForShape(policy, perimeter, limit).ToList();
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
coarsened for arc-heavy parts until the outline is ≤ ~64 vertices, capped at 0.1% of part size).
|
||||
- Candidate rotations come from the part's `RotationPolicy`: for `Automatic`, the four right
|
||||
angles plus the two orientations that axis-align the minimum-area bounding rectangle
|
||||
(`RotatingCalipers`); for sweeps, up to 8 evenly spaced legal steps. Point-symmetric duplicates are dropped.
|
||||
(`RotatingCalipers`); for sweeps, the host policy grid truncated by the engine's orientation limit. Point-symmetric duplicates are dropped.
|
||||
- Each orientation gets a **footprint**: outline inflated (miter joins, so it contains the exact
|
||||
round offset) by `(spacing + 0.022) / 2 + chordTolerance`. Two parts respect the spacing
|
||||
when their footprints don't overlap. The 0.022 covers validators that polygonize arcs
|
||||
circumscribed at 0.01 per side, plus Clipper's 1e-4 grid.
|
||||
round offset) by `spacing / 2 + NestTolerances.SafeClearanceMargin(chordTolerance) / 2`
|
||||
plus four Clipper grid units per footprint. The extra grid allowance preserves this
|
||||
engine's established contact points through repeated NFP Boolean operations; removing it
|
||||
increased mixed-job cost from 1586.94 to 1589.81 in the migration check.
|
||||
- **No-fit polygons** between footprints come from Clipper2 Minkowski sums: an O(n+m)
|
||||
edge merge for convex pairs, and for concave pairs the boundary sweep ∪ (A + p₀) ∪ (−B + a₀).
|
||||
The last two terms cover "B inside A" and "B swallows A". NFPs are cached per orientation pair.
|
||||
@@ -27,14 +28,14 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
legal reference points: the inner-fit rectangle minus the NFPs of everything placed. Each
|
||||
placement subtracts one translated NFP from each region (in parallel, which stays deterministic).
|
||||
Regions only shrink, and an empty region is retired for the rest of the sheet.
|
||||
- At every step all remaining types × orientations compete (there is no fixed placement sequence):
|
||||
- At every step the lowest-number priority with a feasible placement wins; peer types × orientations compete (there is no fixed placement sequence):
|
||||
1. **Gap fill:** if any part fits without pushing the packing front forward, place the
|
||||
*largest* such part at its lowest point.
|
||||
2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the
|
||||
most material coverage for the sheet length it consumes.
|
||||
- The front sweeps along X or Y, which leaves one full-width offcut strip for salvage credit.
|
||||
|
||||
**3. Whole job (`Opus55NestingEngine`, `SheetEconomics`)**
|
||||
**3. Whole job (`Opus55NestingEngine`, `NestJobCost`)**
|
||||
- Sheet by sheet, every available stock size is trial-filled. The trial with the lowest
|
||||
*estimated whole-job cost* (its net area, plus the remaining demand priced at the best
|
||||
efficiency any trial achieved) is committed. This lets a sheet that finishes the job beat a
|
||||
@@ -55,8 +56,7 @@ Every placement decision (which part, which rotation, where, on which sheet) com
|
||||
| `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule |
|
||||
| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) |
|
||||
| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation |
|
||||
| `SheetEconomics.cs` | Net-area objective with salvage credit |
|
||||
| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` |
|
||||
| `tests/` | xUnit suite. Layouts are judged by `Engine.Testing.LayoutAssert` and `NestLayoutCheck` |
|
||||
|
||||
## Build / test
|
||||
|
||||
@@ -65,8 +65,7 @@ dotnet build OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj -c Release
|
||||
dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
|
||||
```
|
||||
|
||||
This project is intentionally **outside** `OpenNest.sln`, the same pattern as the
|
||||
`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin.
|
||||
This project is intentionally **outside** `OpenNest.sln`. It's discovered at runtime as a plugin.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -84,14 +83,25 @@ The engine reports as `Opus55NestingEngine`.
|
||||
## Known limitations
|
||||
|
||||
- **No part-in-part:** holes are treated as solid, so small parts never nest inside cutouts.
|
||||
- **Clearance padding:** gaps are ~0.022 (plus up to the chord tolerance) wider than the
|
||||
required spacing, to stay valid under circumscribed-polygon validators. That's negligible in mm
|
||||
and about 0.02" in inches. The constants are absolute and assume job units near inch/mm scale.
|
||||
- **Clearance padding:** gaps are ~0.003 (plus up to the chord tolerance) wider than the
|
||||
required spacing, to stay valid under `NestValidator`'s 0.001 arc flattening. The margin was
|
||||
0.022 while it assumed a 0.01 validator tolerance. The constants are absolute and assume job
|
||||
units near inch/mm scale.
|
||||
- **Rotation coverage:** `Automatic` parts try at most 8 orientations (fewer when a job has many
|
||||
distinct parts: `48 / partCount`, minimum 2). Free-angle rotations aren't explored beyond the MBR alignment.
|
||||
- **Greedy core:** there is no order/permutation search. The variants and tail re-plan are the only
|
||||
search, and density on small mixed jobs trails what an interlocking-pair filler can reach.
|
||||
- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate`
|
||||
per trial and `PlateCommitted` at the end, with no finer-grained progress.
|
||||
- **Priority is enforced during placement** (lower number first). Progress reports
|
||||
`EvaluatingCandidate` per trial and `PlateCommitted` at the end, with no finer-grained progress.
|
||||
- Parts whose geometry has no readable closed perimeter, or that fit no offered stock at any
|
||||
allowed rotation, are reported unplaced (`NoPlacementFound`) instead of failing the job.
|
||||
|
||||
## Shared services migration
|
||||
|
||||
`JobPartGeometry.TryRead` supplies the perimeter. `ForShape(policy, perimeter, limit)`
|
||||
retains the engine's orientation cap and `DistinctOutlines` drops perimeter symmetry.
|
||||
Stock `WorkArea`/`Fits`, host salvage scoring and `NestJobResultBuilder` replace copied
|
||||
plumbing. The frontier, NFP cache, variant work budget and tail improvement remain local.
|
||||
The shared contract suite exposed and now guards lower-number priority precedence.
|
||||
Every old engine-specific test remains. All five salvage benchmark costs and validity
|
||||
match baseline; see [PR 5 results](../MIGRATION-PR5.md).
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Opus55;
|
||||
|
||||
/// <summary>
|
||||
/// The objective the engine optimizes: sheet area consumed, less the salvage credit for the
|
||||
/// single largest full-width or full-length edge offcut the job's options allow. Packing toward
|
||||
/// one edge (see <see cref="PackAxis"/>) is what makes that offcut large.
|
||||
/// </summary>
|
||||
internal static class SheetEconomics
|
||||
{
|
||||
public static double SheetArea(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||
|
||||
public static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
var area = SheetArea(fill.Stock);
|
||||
var minimum = options.MinimumSalvageDimension;
|
||||
if (options.SalvageRate <= 0 || minimum <= 0 || fill.Parts.Count == 0)
|
||||
return area;
|
||||
|
||||
var work = FrontierPacker.WorkArea(fill.Stock);
|
||||
var gap = fill.Stock.PartSpacing;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var right = fill.Parts.Max(p => p.Right);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
var top = fill.Parts.Max(p => p.Top);
|
||||
var offcuts = new[]
|
||||
{
|
||||
// Box.Length is the X extent, Box.Width the Y extent.
|
||||
(work.Length, bottom - work.Bottom - gap),
|
||||
(work.Length, work.Top - top - gap),
|
||||
(left - work.Left - gap, work.Width),
|
||||
(work.Right - right - gap, work.Width),
|
||||
};
|
||||
var salvage = 0.0;
|
||||
foreach (var (a, b) in offcuts)
|
||||
if (a >= minimum && b >= minimum)
|
||||
salvage = System.Math.Max(salvage, a * b);
|
||||
return area - options.SalvageRate * salvage;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Opus55.csproj" />
|
||||
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
@@ -18,7 +20,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
@@ -44,7 +46,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
}
|
||||
@@ -96,7 +98,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
|
||||
@@ -120,7 +122,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||
{
|
||||
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
|
||||
@@ -138,7 +140,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
@@ -152,7 +154,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
var usage = Assert.Single(result.StockUsage);
|
||||
@@ -171,7 +173,7 @@ public class Opus55NestingEngineTests
|
||||
|
||||
var result = new Opus55NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
}
|
||||
@@ -188,7 +190,25 @@ public class Opus55NestingEngineTests
|
||||
var first = new Opus55NestingEngine().Solve(Build());
|
||||
var second = new Opus55NestingEngine().Solve(Build());
|
||||
|
||||
Assert.Equal(Describe(first), Describe(second));
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
|
||||
}
|
||||
|
||||
[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 Opus55NestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -198,86 +218,6 @@ public class Opus55NestingEngineTests
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------
|
||||
|
||||
private static string Describe(NestJobResult result) =>
|
||||
string.Join(
|
||||
"|",
|
||||
result.Plates.Select(p =>
|
||||
p.StockId + ":" + string.Join(",", p.Placements.Select(x => $"{x.PartId}#{x.InstanceIndex}@{x.X:R},{x.Y:R},{x.Rotation:R}"))
|
||||
)
|
||||
);
|
||||
|
||||
private static void AssertValid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
||||
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
||||
p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (p.Id, p.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var validation = NestValidator.Validate(runs, requirements);
|
||||
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
||||
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
}
|
||||
|
||||
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
private static NestPlateStock Stock(
|
||||
string id,
|
||||
double width,
|
||||
double length,
|
||||
double spacing = 0,
|
||||
Spacing edge = default,
|
||||
int quadrant = 1,
|
||||
int? quantity = null
|
||||
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
|
||||
private static Program Polyline(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
private static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||
private static Program Obround(double length, double width)
|
||||
{
|
||||
var r = width / 2;
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Opus55ContractTests : EngineContractTests<Opus55NestingEngine> { }
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
/// <summary>
|
||||
/// A closed polygon pre-triangulated into flat arrays for allocation-free overlap
|
||||
/// tests. The ear-clip of <see cref="ConvexDecomposition"/> runs ONCE per
|
||||
/// (shape, orientation); per-pair tests then clip cached triangles directly. The
|
||||
/// built-in <see cref="Collision"/> gate re-triangulates both polygons per call and
|
||||
/// allocates a Polygon per clipped region - at the engine's fine collision
|
||||
/// flattening (thousands of edges) that dominated solve time.
|
||||
/// <para>
|
||||
/// Overlap semantics replicate <see cref="Collision.Check"/> exactly: triangle-pair
|
||||
/// half-space clipping (same >=0 inside test, same strict-crossing interpolation,
|
||||
/// same dedupe), the same 2 * Tolerance.Epsilon twice-area floor measured from
|
||||
/// vertex 0, then per-edge outside-piece hole subtraction from both polygons' hole
|
||||
/// sets. Translation is a parameter, so moving a part to a candidate anchor copies
|
||||
/// nothing. When geometry exceeds the scratch bounds the test returns null ("cannot
|
||||
/// decide") and the caller must fall back to the Polygon gate - never a guess.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class TriSet
|
||||
{
|
||||
// Flat vertex pool (local frame) and triangle index triples (CCW).
|
||||
public readonly double[] X;
|
||||
public readonly double[] Y;
|
||||
|
||||
private readonly int[] _ia;
|
||||
private readonly int[] _ib;
|
||||
private readonly int[] _ic;
|
||||
private readonly double[] _tMinX;
|
||||
private readonly double[] _tMinY;
|
||||
private readonly double[] _tMaxX;
|
||||
private readonly double[] _tMaxY;
|
||||
|
||||
public double MinX { get; }
|
||||
public double MinY { get; }
|
||||
public double MaxX { get; }
|
||||
public double MaxY { get; }
|
||||
|
||||
/// <summary>Triangulated holes in the same local frame (empty array when none).</summary>
|
||||
public readonly TriSet[] Holes;
|
||||
|
||||
// Scratch bound: clipped convex pieces stay small; anything larger bails.
|
||||
private const int MaxClipVertices = 48;
|
||||
private const int MaxPieces = 2048;
|
||||
|
||||
private TriSet(
|
||||
double[] x,
|
||||
double[] y,
|
||||
int[] ia,
|
||||
int[] ib,
|
||||
int[] ic,
|
||||
double[] tMinX,
|
||||
double[] tMinY,
|
||||
double[] tMaxX,
|
||||
double[] tMaxY,
|
||||
TriSet[] holes
|
||||
)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
_ia = ia;
|
||||
_ib = ib;
|
||||
_ic = ic;
|
||||
_tMinX = tMinX;
|
||||
_tMinY = tMinY;
|
||||
_tMaxX = tMaxX;
|
||||
_tMaxY = tMaxY;
|
||||
Holes = holes;
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var i = 0; i < x.Length; i++)
|
||||
{
|
||||
if (x[i] < minX)
|
||||
minX = x[i];
|
||||
if (x[i] > maxX)
|
||||
maxX = x[i];
|
||||
if (y[i] < minY)
|
||||
minY = y[i];
|
||||
if (y[i] > maxY)
|
||||
maxY = y[i];
|
||||
}
|
||||
MinX = minX;
|
||||
MinY = minY;
|
||||
MaxX = maxX;
|
||||
MaxY = maxY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ear-clips a polygon ring into cached triangles. Returns null when
|
||||
/// triangulation yields nothing usable - the caller falls back to Polygon gates.
|
||||
/// </summary>
|
||||
public static TriSet? Build(Polygon polygon, IReadOnlyList<Polygon>? holes = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tris = ConvexDecomposition.Triangulate(polygon);
|
||||
var count = tris.Count;
|
||||
if (count == 0)
|
||||
return null;
|
||||
|
||||
var xs = new double[count * 3];
|
||||
var ys = new double[count * 3];
|
||||
var ia = new int[count];
|
||||
var ib = new int[count];
|
||||
var ic = new int[count];
|
||||
var minXA = new double[count];
|
||||
var minYA = new double[count];
|
||||
var maxXA = new double[count];
|
||||
var maxYA = new double[count];
|
||||
|
||||
var k = 0;
|
||||
for (var t = 0; t < count; t++)
|
||||
{
|
||||
var v = tris[t].Vertices; // closed: prev, curr, next, prev
|
||||
ia[t] = k;
|
||||
xs[k] = v[0].X;
|
||||
ys[k] = v[0].Y;
|
||||
k++;
|
||||
ib[t] = k;
|
||||
xs[k] = v[1].X;
|
||||
ys[k] = v[1].Y;
|
||||
k++;
|
||||
ic[t] = k;
|
||||
xs[k] = v[2].X;
|
||||
ys[k] = v[2].Y;
|
||||
k++;
|
||||
minXA[t] = Math.Min(v[0].X, Math.Min(v[1].X, v[2].X));
|
||||
minYA[t] = Math.Min(v[0].Y, Math.Min(v[1].Y, v[2].Y));
|
||||
maxXA[t] = Math.Max(v[0].X, Math.Max(v[1].X, v[2].X));
|
||||
maxYA[t] = Math.Max(v[0].Y, Math.Max(v[1].Y, v[2].Y));
|
||||
}
|
||||
|
||||
TriSet[]? holeSets = null;
|
||||
if (holes != null && holes.Count > 0)
|
||||
{
|
||||
holeSets = new TriSet[holes.Count];
|
||||
for (var h = 0; h < holes.Count; h++)
|
||||
{
|
||||
var holeTris = ConvexDecomposition.Triangulate(holes[h]);
|
||||
if (holeTris.Count == 0)
|
||||
continue;
|
||||
var hx = new double[holeTris.Count * 3];
|
||||
var hy = new double[holeTris.Count * 3];
|
||||
var hia = new int[holeTris.Count];
|
||||
var hib = new int[holeTris.Count];
|
||||
var hic = new int[holeTris.Count];
|
||||
var hminX = new double[holeTris.Count];
|
||||
var hminY = new double[holeTris.Count];
|
||||
var hmaxX = new double[holeTris.Count];
|
||||
var hmaxY = new double[holeTris.Count];
|
||||
var hk = 0;
|
||||
for (var t = 0; t < holeTris.Count; t++)
|
||||
{
|
||||
var v = holeTris[t].Vertices;
|
||||
hia[t] = hk;
|
||||
hx[hk] = v[0].X;
|
||||
hy[hk] = v[0].Y;
|
||||
hk++;
|
||||
hib[t] = hk;
|
||||
hx[hk] = v[1].X;
|
||||
hy[hk] = v[1].Y;
|
||||
hk++;
|
||||
hic[t] = hk;
|
||||
hx[hk] = v[2].X;
|
||||
hy[hk] = v[2].Y;
|
||||
hk++;
|
||||
hminX[t] = Math.Min(v[0].X, Math.Min(v[1].X, v[2].X));
|
||||
hminY[t] = Math.Min(v[0].Y, Math.Min(v[1].Y, v[2].Y));
|
||||
hmaxX[t] = Math.Max(v[0].X, Math.Max(v[1].X, v[2].X));
|
||||
hmaxY[t] = Math.Max(v[0].Y, Math.Max(v[1].Y, v[2].Y));
|
||||
}
|
||||
holeSets[h] = new TriSet(hx, hy, hia, hib, hic, hminX, hminY, hmaxX, hmaxY, null);
|
||||
}
|
||||
}
|
||||
|
||||
return new TriSet(xs, ys, ia, ib, ic, minXA, minYA, maxXA, maxYA, holeSets);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive shared area (surviving both polygons' hole sets) between this
|
||||
/// translated by (adx, ady) and other translated by (bdx, bdy). Returns null
|
||||
/// when the scratch bounds are exceeded and the question cannot be decided.
|
||||
/// </summary>
|
||||
public bool? HasOverlap(TriSet other, double adx, double ady, double bdx, double bdy)
|
||||
{
|
||||
// Same bbox rule as Collision.BoundingBoxesOverlap: overlap must exceed
|
||||
// Tolerance.Epsilon on both axes, so a hairline box overlap never reaches the
|
||||
// clip stage.
|
||||
var eps = OpenNest.Math.Tolerance.Epsilon;
|
||||
var overlapX =
|
||||
Math.Min(MaxX + adx, other.MaxX + bdx) - Math.Max(MinX + adx, other.MinX + bdx);
|
||||
var overlapY =
|
||||
Math.Min(MaxY + ady, other.MaxY + bdy) - Math.Max(MinY + ady, other.MinY + bdy);
|
||||
if (overlapX <= eps || overlapY <= eps)
|
||||
return false;
|
||||
|
||||
var areaFloor = 2 * OpenNest.Math.Tolerance.Epsilon;
|
||||
var clipA = new double[MaxClipVertices * 2];
|
||||
var clipB = new double[MaxClipVertices * 2];
|
||||
var piece = new double[MaxClipVertices * 2];
|
||||
|
||||
for (var ta = 0; ta < _ia.Length; ta++)
|
||||
{
|
||||
var aMinX = _tMinX[ta] + adx;
|
||||
var aMaxX = _tMaxX[ta] + adx;
|
||||
var aMinY = _tMinY[ta] + ady;
|
||||
var aMaxY = _tMaxY[ta] + ady;
|
||||
for (var tb = 0; tb < other._ia.Length; tb++)
|
||||
{
|
||||
var bMinX = other._tMinX[tb] + bdx;
|
||||
var bMaxX = other._tMaxX[tb] + bdx;
|
||||
var bMinY = other._tMinY[tb] + bdy;
|
||||
var bMaxY = other._tMaxY[tb] + bdy;
|
||||
if (
|
||||
Math.Min(aMaxX, bMaxX) - Math.Max(aMinX, bMinX) <= eps
|
||||
|| Math.Min(aMaxY, bMaxY) - Math.Max(aMinY, bMinY) <= eps
|
||||
)
|
||||
continue;
|
||||
|
||||
var count = ClipTriangle(
|
||||
ta, adx, ady, other, tb, bdx, bdy, clipA, clipB, piece
|
||||
);
|
||||
if (count < 3 || count >= MaxClipVertices)
|
||||
continue;
|
||||
if (TwiceArea(piece, count) <= areaFloor)
|
||||
continue;
|
||||
|
||||
var (hasHoles, undecided, survived) = SubtractAllHoles(
|
||||
other, adx, ady, bdx, bdy, piece, count, areaFloor
|
||||
);
|
||||
if (undecided)
|
||||
return null;
|
||||
if (hasHoles)
|
||||
{
|
||||
if (survived)
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return true; // no holes on either side: the clipped region is overlap
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts both polygons' hole triangles from one clipped region, mirroring
|
||||
/// Collision.SubtractHoles: for every hole triangle, every surviving piece is
|
||||
/// split per edge into outside pieces (survivors) and the inside remainder
|
||||
/// (consumed). True means a positive-area piece survived ALL holes.
|
||||
/// </summary>
|
||||
[ThreadStatic]
|
||||
private static List<double[]>? s_pool;
|
||||
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpA;
|
||||
|
||||
[ThreadStatic]
|
||||
private static double[]? s_tmpB;
|
||||
|
||||
private static double[] AcquireBuffer()
|
||||
{
|
||||
var pool = s_pool ??= new List<double[]>();
|
||||
var n = pool.Count;
|
||||
if (n == 0)
|
||||
return new double[MaxClipVertices * 2];
|
||||
var buf = pool[n - 1];
|
||||
pool.RemoveAt(n - 1);
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static void ReleaseBuffer(double[] buf)
|
||||
{
|
||||
var pool = s_pool ??= new List<double[]>();
|
||||
if (pool.Count < 64)
|
||||
pool.Add(buf);
|
||||
}
|
||||
|
||||
private static (double[] Tmp, double[] Inside) ScratchPair()
|
||||
{
|
||||
s_tmpA ??= new double[MaxClipVertices * 2];
|
||||
s_tmpB ??= new double[MaxClipVertices * 2];
|
||||
return (s_tmpA, s_tmpB);
|
||||
}
|
||||
|
||||
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
|
||||
TriSet other,
|
||||
double adx,
|
||||
double ady,
|
||||
double bdx,
|
||||
double bdy,
|
||||
double[] piece,
|
||||
int count,
|
||||
double areaFloor
|
||||
)
|
||||
{
|
||||
var allHoles = 0;
|
||||
if (Holes != null)
|
||||
allHoles += Holes.Length;
|
||||
if (other.Holes != null)
|
||||
allHoles += other.Holes.Length;
|
||||
if (allHoles == 0)
|
||||
return (false, false, false);
|
||||
|
||||
// pieces[0] is the caller's own buffer - never release it back to the pool.
|
||||
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
|
||||
var owned = new HashSet<double[]>();
|
||||
|
||||
bool SubtractOwner(TriSet owner, double odx, double ody)
|
||||
{
|
||||
if (owner.Holes == null)
|
||||
return true;
|
||||
for (var h = 0; h < owner.Holes.Length && pieces.Count > 0; h++)
|
||||
{
|
||||
var hole = owner.Holes[h];
|
||||
if (hole == null)
|
||||
continue; // untriangulatable hole: nothing to subtract
|
||||
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
|
||||
{
|
||||
var hMinX = hole._tMinX[t] + odx;
|
||||
var hMaxX = hole._tMaxX[t] + odx;
|
||||
var hMinY = hole._tMinY[t] + ody;
|
||||
var hMaxY = hole._tMaxY[t] + ody;
|
||||
|
||||
var next = new List<(double[], int)>();
|
||||
for (var p = 0; p < pieces.Count; p++)
|
||||
{
|
||||
var (buf, pc) = pieces[p];
|
||||
|
||||
// Piece bbox (built-in uses <=: touching skips subtraction).
|
||||
var pMinX = double.MaxValue;
|
||||
var pMinY = double.MaxValue;
|
||||
var pMaxX = double.MinValue;
|
||||
var pMaxY = double.MinValue;
|
||||
for (var v = 0; v < pc; v++)
|
||||
{
|
||||
var px = buf[v * 2];
|
||||
var py = buf[v * 2 + 1];
|
||||
if (px < pMinX)
|
||||
pMinX = px;
|
||||
if (px > pMaxX)
|
||||
pMaxX = px;
|
||||
if (py < pMinY)
|
||||
pMinY = py;
|
||||
if (py > pMaxY)
|
||||
pMaxY = py;
|
||||
}
|
||||
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
|
||||
{
|
||||
next.Add((buf, pc));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clip the piece against the hole triangle's three edges: the
|
||||
// outside of each edge survives as its own piece; the inside
|
||||
// remainder continues into the next edge. The remainder inside
|
||||
// all three edges is consumed (the hole ate it).
|
||||
var rem = AcquireBuffer();
|
||||
Array.Copy(buf, rem, pc * 2);
|
||||
var remCount = pc;
|
||||
var (tmp, insideBuf) = ScratchPair();
|
||||
for (var e = 0; e < 3 && remCount >= 3; e++)
|
||||
{
|
||||
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
|
||||
var ej = e == 0 ? hole._ib[t] : e == 1 ? hole._ic[t] : hole._ia[t];
|
||||
var sx = hole.X[ei] + odx;
|
||||
var sy = hole.Y[ei] + ody;
|
||||
var ex = hole.X[ej] + odx;
|
||||
var ey = hole.Y[ej] + ody;
|
||||
|
||||
var outCount =
|
||||
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, false, tmp);
|
||||
if (outCount >= 3 && TwiceArea(tmp, outCount) > areaFloor)
|
||||
{
|
||||
if (next.Count >= MaxPieces)
|
||||
return false; // undecided
|
||||
var keep = AcquireBuffer();
|
||||
owned.Add(keep);
|
||||
Array.Copy(tmp, keep, outCount * 2);
|
||||
next.Add((keep, outCount));
|
||||
}
|
||||
remCount =
|
||||
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
|
||||
if (remCount >= MaxClipVertices)
|
||||
return false; // undecided
|
||||
Array.Copy(insideBuf, rem, remCount * 2);
|
||||
}
|
||||
// The inside-all-edges remainder is consumed by the hole: drop it.
|
||||
ReleaseBuffer(rem);
|
||||
if (owned.Remove(buf))
|
||||
ReleaseBuffer(buf);
|
||||
}
|
||||
pieces = next;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
|
||||
return (true, true, false);
|
||||
|
||||
foreach (var (buf, pc) in pieces)
|
||||
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
|
||||
return (true, false, true);
|
||||
return (true, false, false);
|
||||
}
|
||||
|
||||
private static IEnumerable<(double[] Buf, int Count)> Enumerate(
|
||||
List<double[]> bufs,
|
||||
List<int> counts
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < bufs.Count; i++)
|
||||
yield return (bufs[i], counts[i]);
|
||||
}
|
||||
|
||||
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
|
||||
private int ClipTriangle(
|
||||
int ta,
|
||||
double adx,
|
||||
double ady,
|
||||
TriSet other,
|
||||
int tb,
|
||||
double bdx,
|
||||
double bdy,
|
||||
double[] bufA,
|
||||
double[] bufB,
|
||||
double[] piece
|
||||
)
|
||||
{
|
||||
var ia = _ia[ta];
|
||||
var ib = _ib[ta];
|
||||
var ic = _ic[ta];
|
||||
bufA[0] = X[ia] + adx;
|
||||
bufA[1] = Y[ia] + ady;
|
||||
bufA[2] = X[ib] + adx;
|
||||
bufA[3] = Y[ib] + ady;
|
||||
bufA[4] = X[ic] + adx;
|
||||
bufA[5] = Y[ic] + ady;
|
||||
var count = 3;
|
||||
|
||||
for (var e = 0; e < 3 && count >= 3; e++)
|
||||
{
|
||||
var ei = e == 0 ? other._ia[tb] : e == 1 ? other._ib[tb] : other._ic[tb];
|
||||
var ej = e == 0 ? other._ib[tb] : e == 1 ? other._ic[tb] : other._ia[tb];
|
||||
var sx = other.X[ei] + bdx;
|
||||
var sy = other.Y[ei] + bdy;
|
||||
var ex = other.X[ej] + bdx;
|
||||
var ey = other.Y[ej] + bdy;
|
||||
count = ClipHalfSpace(bufA, count, sx, sy, ex, ey, true, bufB);
|
||||
if (count >= MaxClipVertices)
|
||||
return count;
|
||||
for (var v = 0; v < count * 2; v++)
|
||||
bufA[v] = bufB[v];
|
||||
}
|
||||
for (var v = 0; v < Math.Min(count, MaxClipVertices) * 2; v++)
|
||||
piece[v] = bufA[v];
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sutherland-Hodgman clip against one directed edge's half-plane; identical
|
||||
/// classification, interpolation and dedupe to Collision.ClipHalfSpace.
|
||||
/// </summary>
|
||||
private static int ClipHalfSpace(
|
||||
double[] verts,
|
||||
int count,
|
||||
double sx,
|
||||
double sy,
|
||||
double ex,
|
||||
double ey,
|
||||
bool inside,
|
||||
double[] outBuf
|
||||
)
|
||||
{
|
||||
var kept = 0;
|
||||
var cap = outBuf.Length / 2;
|
||||
var edgeX = ex - sx;
|
||||
var edgeY = ey - sy;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var j = (i + 1) % count;
|
||||
var cx = verts[i * 2];
|
||||
var cy = verts[i * 2 + 1];
|
||||
var nx = verts[j * 2];
|
||||
var ny = verts[j * 2 + 1];
|
||||
var cd = edgeX * (cy - sy) - edgeY * (cx - sx);
|
||||
var nd = edgeX * (ny - sy) - edgeY * (nx - sx);
|
||||
if (inside ? cd >= 0 : cd <= 0)
|
||||
{
|
||||
if (kept >= cap)
|
||||
return cap; // overflow: caller treats as undecided
|
||||
kept = AddDistinct(outBuf, kept, cx, cy);
|
||||
}
|
||||
if ((cd < 0 && nd > 0) || (cd > 0 && nd < 0))
|
||||
{
|
||||
if (kept >= cap)
|
||||
return cap; // overflow
|
||||
var t = cd / (cd - nd);
|
||||
kept = AddDistinct(
|
||||
outBuf, kept, cx + t * (nx - cx), cy + t * (ny - cy)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (kept > 1 && outBuf[0] == outBuf[(kept - 1) * 2] && outBuf[1] == outBuf[(kept - 1) * 2 + 1])
|
||||
kept--;
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static int AddDistinct(double[] buf, int count, double x, double y)
|
||||
{
|
||||
if (count > 0 && buf[(count - 1) * 2] == x && buf[(count - 1) * 2 + 1] == y)
|
||||
return count;
|
||||
buf[count * 2] = x;
|
||||
buf[count * 2 + 1] = y;
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
/// <summary>Twice the area, relative to vertex 0 (cancellation-safe).</summary>
|
||||
private static double TwiceArea(double[] verts, int count)
|
||||
{
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < count; i++)
|
||||
twiceArea +=
|
||||
(verts[i * 2] - verts[0]) * (verts[(i + 1) * 2 + 1] - verts[1])
|
||||
- (verts[i * 2 + 1] - verts[1]) * (verts[(i + 1) * 2] - verts[0]);
|
||||
return Math.Abs(twiceArea);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned bounding box with no allocation and inclusive intersection tests.
|
||||
/// </summary>
|
||||
internal readonly struct Bounds
|
||||
{
|
||||
public Bounds(double minX, double minY, double maxX, double maxY)
|
||||
{
|
||||
MinX = minX;
|
||||
MinY = minY;
|
||||
MaxX = maxX;
|
||||
MaxY = maxY;
|
||||
}
|
||||
|
||||
public double MinX { get; }
|
||||
public double MinY { get; }
|
||||
public double MaxX { get; }
|
||||
public double MaxY { get; }
|
||||
|
||||
public bool Intersects(in Bounds other, double margin = 0) =>
|
||||
other.MinX <= MaxX + margin
|
||||
&& MinX <= other.MaxX + margin
|
||||
&& other.MinY <= MaxY + margin
|
||||
&& MinY <= other.MaxY + margin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A convex contour as flat coordinate arrays (closed: last point != first), with
|
||||
/// O(log n) strict-inside and exact vertical/horizontal span queries. This is the
|
||||
/// engine's own working representation for No-Fit-Polygon geometry; nothing here is
|
||||
/// shared with the built-in nesters.
|
||||
/// </summary>
|
||||
internal sealed class ConvexContour
|
||||
{
|
||||
// Numerical inset: points within this depth of the boundary count as outside, so a
|
||||
// placement resting on the NFP (hull contact) is accepted.
|
||||
public const double Surface = 1e-6;
|
||||
|
||||
private readonly double[] _x;
|
||||
private readonly double[] _y;
|
||||
|
||||
private ConvexContour(double[] x, double[] y, Bounds bounds)
|
||||
{
|
||||
_x = x;
|
||||
_y = y;
|
||||
Bounds = bounds;
|
||||
_ = FindStart();
|
||||
}
|
||||
|
||||
public Bounds Bounds { get; }
|
||||
public int Count => _x.Length;
|
||||
|
||||
/// <summary>Index of the lexicographic (Y, X) minimum vertex.</summary>
|
||||
public int Start { get; private set; }
|
||||
|
||||
public double X(int i) => _x[i];
|
||||
public double Y(int i) => _y[i];
|
||||
|
||||
public static ConvexContour FromVertices(IList<Vector> points)
|
||||
{
|
||||
var n = points.Count;
|
||||
if (n > 1 && points[0].Equals(points[n - 1]))
|
||||
n--;
|
||||
if (n < 3)
|
||||
throw new ArgumentException("Convex contour needs at least three vertices.");
|
||||
|
||||
var x = new double[n];
|
||||
var y = new double[n];
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
x[i] = points[i].X;
|
||||
y[i] = points[i].Y;
|
||||
if (x[i] < minX)
|
||||
minX = x[i];
|
||||
if (x[i] > maxX)
|
||||
maxX = x[i];
|
||||
if (y[i] < minY)
|
||||
minY = y[i];
|
||||
if (y[i] > maxY)
|
||||
maxY = y[i];
|
||||
}
|
||||
return new ConvexContour(x, y, new Bounds(minX, minY, maxX, maxY));
|
||||
}
|
||||
|
||||
/// <summary>Regular 2^k-gon approximating a disk of the given radius (convex CCW).</summary>
|
||||
public static ConvexContour Disk(double radius, int segments = 32)
|
||||
{
|
||||
var x = new double[segments];
|
||||
var y = new double[segments];
|
||||
for (var i = 0; i < segments; i++)
|
||||
{
|
||||
var angle = 2 * Math.PI * i / segments;
|
||||
x[i] = radius * Math.Cos(angle);
|
||||
y[i] = radius * Math.Sin(angle);
|
||||
}
|
||||
return new ConvexContour(x, y, new Bounds(-radius, -radius, radius, radius));
|
||||
}
|
||||
|
||||
public ConvexContour Translated(double dx, double dy)
|
||||
{
|
||||
var n = _x.Length;
|
||||
var x = new double[n];
|
||||
var y = new double[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
x[i] = _x[i] + dx;
|
||||
y[i] = _y[i] + dy;
|
||||
}
|
||||
return new ConvexContour(x, y, new Bounds(Bounds.MinX + dx, Bounds.MinY + dy, Bounds.MaxX + dx, Bounds.MaxY + dy));
|
||||
}
|
||||
|
||||
public double MinX => Bounds.MinX;
|
||||
public double MinY => Bounds.MinY;
|
||||
public double MaxX => Bounds.MaxX;
|
||||
public double MaxY => Bounds.MaxY;
|
||||
|
||||
/// <summary>
|
||||
/// Containment with a <see cref="Surface"/> band: points strictly outside return
|
||||
/// false; points inside - OR within the band of an edge - return true, so anchors
|
||||
/// resting on the NFP (the usual corner-candidate case) fall through to the exact
|
||||
/// material gate instead of being certified by the fast path. The inset may never
|
||||
/// exceed the circumscribed spacing disk's chord slack (Disk radius r/cos(pi/24)),
|
||||
/// so a hull contact that still clears the true spacing passes the gate.
|
||||
/// </summary>
|
||||
public bool ContainsPoint(double px, double py)
|
||||
{
|
||||
var n = _x.Length;
|
||||
var sx = _x[Start];
|
||||
var sy = _y[Start];
|
||||
|
||||
// Polar-angle wedge from the start vertex (CCW order: first -> last).
|
||||
var first = Mod(Start + 1, n);
|
||||
var last = Mod(Start - 1, n);
|
||||
var head = Cross(sx, sy, _x[first], _y[first], px, py);
|
||||
if (head < -Surface)
|
||||
return false;
|
||||
var tail = Cross(sx, sy, _x[last], _y[last], px, py);
|
||||
if (tail > Surface)
|
||||
return false;
|
||||
// Within the band of the two wedge rays: conservative inside.
|
||||
if (head <= Surface || tail >= -Surface)
|
||||
return true;
|
||||
|
||||
// Binary search for the fan triangle (start, vk, vk+1) bracketing the ray
|
||||
// start->p; vk is CCW-ordered so polar angle rises monotonically first->last.
|
||||
var lo = 0; // offset (from first) of the last vertex at-or-before p's angle
|
||||
var hi = n - 2; // offset of last
|
||||
while (hi - lo > 1)
|
||||
{
|
||||
var mid = (lo + hi) / 2;
|
||||
var index = Mod(Start + 1 + mid, n);
|
||||
if (Cross(sx, sy, _x[index], _y[index], px, py) >= -Surface)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
var a = Mod(Start + 1 + lo, n);
|
||||
var b = Mod(Start + 1 + lo + 1, n);
|
||||
var edgeAB = Cross(_x[a], _y[a], _x[b], _y[b], px, py);
|
||||
if (edgeAB < -Surface)
|
||||
return false;
|
||||
// Strictly inside the fan triangle, or inside the band of the far edge.
|
||||
return edgeAB <= Surface
|
||||
|| Cross(sx, sy, _x[a], _y[a], px, py) >= -Surface
|
||||
&& Cross(_x[b], _y[b], sx, sy, px, py) >= -Surface;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The vertical span [lo, hi] of the contour's cross-section at x, when x is
|
||||
/// strictly inside its x-range (inset by <see cref="Surface"/>); false otherwise.
|
||||
/// </summary>
|
||||
public bool VerticalSpanAt(double x, out double lo, out double hi)
|
||||
{
|
||||
lo = 0;
|
||||
hi = 0;
|
||||
if (x < MinX + Surface || x > MaxX - Surface)
|
||||
return false;
|
||||
|
||||
lo = double.MaxValue;
|
||||
hi = double.MinValue;
|
||||
var n = _x.Length;
|
||||
var j = n - 1;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var x0 = _x[j];
|
||||
var x1 = _x[i];
|
||||
if ((x0 <= x && x1 >= x) || (x1 <= x && x0 >= x))
|
||||
{
|
||||
var y0 = _y[j];
|
||||
var y1 = _y[i];
|
||||
double y;
|
||||
if (x1 == x0)
|
||||
y = Math.Min(y0, y1);
|
||||
else
|
||||
y = y0 + (y1 - y0) * (x - x0) / (x1 - x0);
|
||||
if (y < lo)
|
||||
lo = y;
|
||||
if (y > hi)
|
||||
hi = y;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
return lo <= hi;
|
||||
}
|
||||
|
||||
/// <summary>The horizontal span at y, inset like <see cref="VerticalSpanAt"/>.</summary>
|
||||
public bool HorizontalSpanAt(double y, out double lo, out double hi)
|
||||
{
|
||||
lo = 0;
|
||||
hi = 0;
|
||||
if (y < MinY + Surface || y > MaxY - Surface)
|
||||
return false;
|
||||
|
||||
lo = double.MaxValue;
|
||||
hi = double.MinValue;
|
||||
var n = _x.Length;
|
||||
var j = n - 1;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var y0 = _y[j];
|
||||
var y1 = _y[i];
|
||||
if ((y0 <= y && y1 >= y) || (y1 <= y && y0 >= y))
|
||||
{
|
||||
var x0 = _x[j];
|
||||
var x1 = _x[i];
|
||||
double x;
|
||||
if (y1 == y0)
|
||||
x = Math.Min(x0, x1);
|
||||
else
|
||||
x = x0 + (x1 - x0) * (y - y0) / (y1 - y0);
|
||||
if (x < lo)
|
||||
lo = x;
|
||||
if (x > hi)
|
||||
hi = x;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
return lo <= hi;
|
||||
}
|
||||
|
||||
private int Mod(int i, int n)
|
||||
{
|
||||
var m = i % n;
|
||||
return m < 0 ? m + n : m;
|
||||
}
|
||||
|
||||
private int FindStart()
|
||||
{
|
||||
var best = 0;
|
||||
for (var i = 1; i < _y.Length; i++)
|
||||
if (
|
||||
_y[i] < _y[best] - 1e-12
|
||||
|| (Math.Abs(_y[i] - _y[best]) <= 1e-12 && _x[i] < _x[best])
|
||||
)
|
||||
best = i;
|
||||
Start = best;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Cross(double ax, double ay, double bx, double by, double px, double py) =>
|
||||
(bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-Fit-Polygon geometry for this engine: the Minkowski sum of two convex contours
|
||||
/// (the classic linear edge-merge), used to build convex NFPs as
|
||||
/// placedHull (+) disk(spacing) (+) reflect(candidateHull). The engine's placement
|
||||
/// search consumes these contours directly; it never tessellates part material or
|
||||
/// delegates to the built-in NFP machinery.
|
||||
/// </summary>
|
||||
internal static class NfpGeometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-symmetric reflection (rotation by 180 degrees). Negating every vertex
|
||||
/// preserves CCW winding, so the vertex order must NOT be reversed - reversing it
|
||||
/// would hand the edge-merge a CW contour and corrupt the NFP.
|
||||
/// </summary>
|
||||
public static ConvexContour Reflect(ConvexContour contour)
|
||||
{
|
||||
var n = contour.Count;
|
||||
var points = new List<Vector>(n);
|
||||
for (var i = 0; i < n; i++)
|
||||
points.Add(new Vector(-contour.X(i), -contour.Y(i)));
|
||||
return ConvexContour.FromVertices(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minkowski sum of two convex CCW contours via angular edge merge, starting from
|
||||
/// the sum of each contour's lexicographic (Y, X) minimum vertex. Edges are chosen
|
||||
/// by relative angle (cross product); the invariant that the two frontier edges are
|
||||
/// always less than 180 degrees apart holds because both walks start at the lowest
|
||||
/// vertex and each convex polygon turns by less than 180 degrees per vertex.
|
||||
/// </summary>
|
||||
public static ConvexContour Minkowski(ConvexContour a, ConvexContour b)
|
||||
{
|
||||
var na = a.Count;
|
||||
var nb = b.Count;
|
||||
var edges = new List<(double x, double y)>(na + nb);
|
||||
|
||||
// Edge vectors walking CCW from each start vertex.
|
||||
var edgeA = new (double x, double y)[na];
|
||||
for (var k = 0; k < na; k++)
|
||||
{
|
||||
var p = (a.Start + k) % na;
|
||||
var q = (a.Start + k + 1) % na;
|
||||
edgeA[k] = (a.X(q) - a.X(p), a.Y(q) - a.Y(p));
|
||||
}
|
||||
var edgeB = new (double x, double y)[nb];
|
||||
for (var k = 0; k < nb; k++)
|
||||
{
|
||||
var p = (b.Start + k) % nb;
|
||||
var q = (b.Start + k + 1) % nb;
|
||||
edgeB[k] = (b.X(q) - b.X(p), b.Y(q) - b.Y(p));
|
||||
}
|
||||
|
||||
var ka = 0;
|
||||
var kb = 0;
|
||||
while (ka < na || kb < nb)
|
||||
{
|
||||
if (ka >= na)
|
||||
{
|
||||
edges.Add(edgeB[kb++]);
|
||||
continue;
|
||||
}
|
||||
if (kb >= nb)
|
||||
{
|
||||
edges.Add(edgeA[ka++]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var ea = edgeA[ka];
|
||||
var eb = edgeB[kb];
|
||||
var cross = ea.x * eb.y - ea.y * eb.x;
|
||||
var scale =
|
||||
(ea.x * ea.x + ea.y * ea.y) * (eb.x * eb.x + eb.y * eb.y) + 1e-300;
|
||||
if (Math.Abs(cross) <= 1e-9 * Math.Sqrt(scale))
|
||||
{
|
||||
// Same direction: emit the summed edge.
|
||||
edges.Add((ea.x + eb.x, ea.y + eb.y));
|
||||
ka++;
|
||||
kb++;
|
||||
}
|
||||
else if (cross > 0)
|
||||
{
|
||||
// cross(ea, eb) > 0: eb is CCW-after ea, so ea is the more clockwise
|
||||
// edge and must be emitted first to keep the merge in angular order.
|
||||
edges.Add(ea);
|
||||
ka++;
|
||||
}
|
||||
else
|
||||
{
|
||||
edges.Add(eb);
|
||||
kb++;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<Vector>(edges.Count + 1);
|
||||
var px = a.X(a.Start) + b.X(b.Start);
|
||||
var py = a.Y(a.Start) + b.Y(b.Start);
|
||||
result.Add(new Vector(px, py));
|
||||
foreach (var (ex, ey) in edges)
|
||||
{
|
||||
px += ex;
|
||||
py += ey;
|
||||
result.Add(new Vector(px, py));
|
||||
}
|
||||
if (result.Count > 1 && result[0].Equals(result[^1]))
|
||||
result.RemoveAt(result.Count - 1);
|
||||
return ConvexContour.FromVertices(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
/// <summary>
|
||||
/// Flat-array polygon with a uniform edge grid, used as the engine's fast outer-shell
|
||||
/// clearance test. Two closed polygons share positive area only when an edge pair
|
||||
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
|
||||
/// happening certifies the two closed regions (hence any materials inside them) are
|
||||
/// clear. <see cref="Clears"/> returns true only in that certified case and false
|
||||
/// whenever anything touches, so it can only ever skip the exact <see cref="Collision"/>
|
||||
/// gate when the exact gate would also find no overlap - the exact gate triangulates
|
||||
/// both polygons per call and dominates runtime on finely flattened arc geometry.
|
||||
/// <para>
|
||||
/// A <see cref="FastPolyTemplate"/> holds the shared geometry; <see cref="Translated"/>
|
||||
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
|
||||
/// all cell indices unchanged, only the predicate coordinates shift.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class FastPoly
|
||||
{
|
||||
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
|
||||
private const double TouchEps = 1e-9;
|
||||
|
||||
private readonly FastPolyTemplate _template;
|
||||
|
||||
/// <summary>Translation applied to the shared template geometry.</summary>
|
||||
public readonly double Dx;
|
||||
|
||||
public readonly double Dy;
|
||||
|
||||
private FastPoly(FastPolyTemplate template, double dx, double dy)
|
||||
{
|
||||
_template = template;
|
||||
Dx = dx;
|
||||
Dy = dy;
|
||||
}
|
||||
|
||||
public double MinX => _template.MinX + Dx;
|
||||
public double MinY => _template.MinY + Dy;
|
||||
public double MaxX => _template.MaxX + Dx;
|
||||
public double MaxY => _template.MaxY + Dy;
|
||||
|
||||
/// <summary>
|
||||
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
|
||||
/// Returns null when the polygon has no usable ring - callers treat that as
|
||||
/// "no information" and fall through to the exact gate.
|
||||
/// </summary>
|
||||
public static FastPoly? From(Polygon polygon)
|
||||
{
|
||||
var template = FastPolyTemplate.Build(polygon);
|
||||
return template == null ? null : new FastPoly(template, 0, 0);
|
||||
}
|
||||
|
||||
public FastPoly Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
|
||||
|
||||
private double X(int i) => _template.X[i] + Dx;
|
||||
private double Y(int i) => _template.Y[i] + Dy;
|
||||
|
||||
/// <summary>
|
||||
/// True when this and <paramref name="other"/> are CERTIFIED clear: their
|
||||
/// boundaries neither cross nor touch (within <see cref="TouchEps"/>) and neither
|
||||
/// contains a vertex of the other, so the closed regions share no area. Any touch,
|
||||
/// crossing, or containment reports false and defers to the exact gate.
|
||||
/// </summary>
|
||||
public static bool Clears(FastPoly a, FastPoly b) => Relate(a, b) == FastRelation.Clear;
|
||||
|
||||
/// <summary>
|
||||
/// Outer-shell relation between two closed polygons: crossing or containment means
|
||||
/// the shells share positive area; a boundary touch alone or disjoint shells means
|
||||
/// they do not. The Overlap verdict is about SHELLS only - callers with holes must
|
||||
/// still consult the exact gate, because holes can cancel shell overlap.
|
||||
/// </summary>
|
||||
public static FastRelation Relate(FastPoly a, FastPoly b)
|
||||
{
|
||||
if (
|
||||
a.MaxX <= b.MinX
|
||||
|| b.MaxX <= a.MinX
|
||||
|| a.MaxY <= b.MinY
|
||||
|| b.MaxY <= a.MinY
|
||||
)
|
||||
return FastRelation.Clear; // disjoint bounding boxes
|
||||
|
||||
// One walk per direction reports the strongest edge relation: a transversal
|
||||
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
|
||||
// area but may hide a crossing in near-degenerate coordinates (unknown).
|
||||
var edge = EdgeRelation(a, b);
|
||||
if (edge < 2)
|
||||
{
|
||||
var back = EdgeRelation(b, a);
|
||||
if (back > edge)
|
||||
edge = back;
|
||||
}
|
||||
if (edge == 2)
|
||||
return FastRelation.Overlap;
|
||||
|
||||
// No transversal crossing. Cases:
|
||||
// 0 = boundaries fully disjoint: containment (hence positive overlap) is
|
||||
// decided by one vertex test per direction.
|
||||
// 1 = point touches only (zero shared area by themselves): positive overlap
|
||||
// requires a vertex strictly inside the other polygon; a tangency - the
|
||||
// spacing-exact contact a bottom-left packer lives on - has none.
|
||||
// 3 = collinear/near-degenerate contact: a shared boundary strip can hide a
|
||||
// same-side positive overlap with no strict-interior vertex anywhere, so
|
||||
// it defers to the exact gate.
|
||||
switch (edge)
|
||||
{
|
||||
case 0:
|
||||
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
|
||||
return FastRelation.Overlap;
|
||||
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
|
||||
return FastRelation.Overlap;
|
||||
return FastRelation.Clear;
|
||||
case 1:
|
||||
if (AnyVertexStrictlyInside(b, a) || AnyVertexStrictlyInside(a, b))
|
||||
return FastRelation.Overlap;
|
||||
return FastRelation.Clear;
|
||||
default:
|
||||
return FastRelation.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when any vertex of <paramref name="vertexSource"/> lies strictly inside
|
||||
/// <paramref name="poly"/>, or any edge interior sample point does. The samples
|
||||
/// close the inscribed-polygon hole: positive shared area with boundaries meeting
|
||||
/// only at clean points, no strict-interior vertex, and no collinear contact
|
||||
/// requires an edge to run through the interior - its quarter points catch that.
|
||||
/// </summary>
|
||||
private static bool AnyVertexStrictlyInside(FastPoly poly, FastPoly vertexSource)
|
||||
{
|
||||
var n = vertexSource._template.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var vx = vertexSource.X(i);
|
||||
var vy = vertexSource.Y(i);
|
||||
if (ContainsPointStrictly(poly, vx, vy))
|
||||
return true;
|
||||
var i2 = (i + 1) % n;
|
||||
var wx = vertexSource.X(i2);
|
||||
var wy = vertexSource.Y(i2);
|
||||
if (wx == vx && wy == vy)
|
||||
continue;
|
||||
for (var k = 1; k <= 3; k++)
|
||||
{
|
||||
var t = k * 0.25;
|
||||
if (ContainsPointStrictly(poly, vx + (wx - vx) * t, vy + (wy - vy) * t))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Three-state outcome of <see cref="Relate"/>.</summary>
|
||||
public enum FastRelation
|
||||
{
|
||||
/// <summary>Shells certified disjoint: any materials inside them are clear.</summary>
|
||||
Clear,
|
||||
|
||||
/// <summary>Shells share positive area (crossing or containment).</summary>
|
||||
Overlap,
|
||||
|
||||
/// <summary>Boundary touch too close to classify: consult the exact gate.</summary>
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when any edge of <paramref name="q"/> crosses or touches the boundary of
|
||||
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
|
||||
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
|
||||
/// query edge is converted by subtracting p's translation first.
|
||||
/// </summary>
|
||||
private static int EdgeRelation(FastPoly p, FastPoly q)
|
||||
{
|
||||
var t = p._template;
|
||||
var n = t.Count;
|
||||
var seen = t.Seen;
|
||||
var head = t.Head;
|
||||
var nodeEdge = t.NodeEdge;
|
||||
var nodeNext = t.NodeNext;
|
||||
var no = q._template.Count;
|
||||
var strongest = 0;
|
||||
|
||||
for (var e = 0; e < no; e++)
|
||||
{
|
||||
// Stamp per QUERY edge: a grid edge may need testing against every query
|
||||
// edge; the dedupe only collapses cells an individual query edge crosses
|
||||
// more than once.
|
||||
var stamp = ++t.Stamp;
|
||||
var i2 = (e + 1) % no;
|
||||
var p0x = q.X(e) - p.Dx;
|
||||
var p0y = q.Y(e) - p.Dy;
|
||||
var p1x = q.X(i2) - p.Dx;
|
||||
var p1y = q.Y(i2) - p.Dy;
|
||||
|
||||
var c0 = ColLow(t, p0x, p1x);
|
||||
if (c0 > ColHigh(t, p0x, p1x))
|
||||
continue;
|
||||
var c1 = ColHigh(t, p0x, p1x);
|
||||
var r0 = RowLow(t, p0y, p1y);
|
||||
if (r0 > RowHigh(t, p0y, p1y))
|
||||
continue;
|
||||
var r1 = RowHigh(t, p0y, p1y);
|
||||
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
|
||||
{
|
||||
var ea = nodeEdge[nIdx];
|
||||
if (seen[ea] == stamp)
|
||||
continue;
|
||||
seen[ea] = stamp;
|
||||
var a2 = (ea + 1) % n;
|
||||
var relation = SegmentRelation(
|
||||
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
|
||||
);
|
||||
if (relation == 2)
|
||||
return 2; // transversal crossing
|
||||
if (relation > strongest)
|
||||
strongest = relation;
|
||||
}
|
||||
}
|
||||
return strongest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
|
||||
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
|
||||
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
|
||||
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
|
||||
/// either a same-side positive overlap or an opposite-side tangency, so it must
|
||||
/// defer to the exact gate); 0 = disjoint.
|
||||
/// </summary>
|
||||
private static int SegmentRelation(
|
||||
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
|
||||
)
|
||||
{
|
||||
var rx = bx - ax;
|
||||
var ry = by - ay;
|
||||
var sx = dx - cx;
|
||||
var sy = dy - cy;
|
||||
var d1 = rx * (cy - ay) - ry * (cx - ax);
|
||||
var d2 = rx * (dy - ay) - ry * (dx - ax);
|
||||
var d3 = sx * (ay - cy) - sy * (ax - cx);
|
||||
var d4 = sx * (by - cy) - sy * (bx - cx);
|
||||
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
||||
return 2; // proper crossing
|
||||
|
||||
// A near-zero orientation means the configuration is collinear or too close to
|
||||
// classify; only exact-zero orientations get the clean point-touch verdict.
|
||||
var scale = Math.Max(
|
||||
1e-30,
|
||||
Math.Max(Math.Abs(rx) + Math.Abs(ry), Math.Abs(sx) + Math.Abs(sy))
|
||||
);
|
||||
var eps = TouchEps * scale;
|
||||
var nearDegenerate =
|
||||
(Math.Abs(d1) <= eps && d1 != 0)
|
||||
|| (Math.Abs(d2) <= eps && d2 != 0)
|
||||
|| (Math.Abs(d3) <= eps && d3 != 0)
|
||||
|| (Math.Abs(d4) <= eps && d4 != 0);
|
||||
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
|
||||
|
||||
var touch =
|
||||
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|
||||
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|
||||
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|
||||
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
|
||||
|
||||
if (nearDegenerate)
|
||||
return 3;
|
||||
if (exactDegenerate)
|
||||
// Collinear: contact along a segment (or too close to tell) must defer to
|
||||
// the exact gate; collinear but disjoint edges simply do not touch.
|
||||
return touch ? 3 : 0;
|
||||
if (touch)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool PointOnSegment(
|
||||
double px, double py, double ax, double ay, double bx, double by
|
||||
) =>
|
||||
Math.Min(ax, bx) - TouchEps <= px
|
||||
&& px <= Math.Max(ax, bx) + TouchEps
|
||||
&& Math.Min(ay, by) - TouchEps <= py
|
||||
&& py <= Math.Max(ay, by) + TouchEps;
|
||||
|
||||
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
|
||||
private static bool ContainsPointStrictly(FastPoly poly, double px, double py)
|
||||
{
|
||||
var t = poly._template;
|
||||
var inside = false;
|
||||
var n = t.Count;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var j = (i + 1) % n;
|
||||
var yi = poly.Y(i);
|
||||
var yj = poly.Y(j);
|
||||
if ((yi > py) != (yj > py))
|
||||
{
|
||||
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
|
||||
if (px < xAt)
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static int ColLow(FastPolyTemplate t, double a, double b) =>
|
||||
Math.Clamp((int)Math.Floor((Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
|
||||
|
||||
private static int ColHigh(FastPolyTemplate t, double a, double b) =>
|
||||
Math.Clamp((int)Math.Floor((Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
|
||||
|
||||
private static int RowLow(FastPolyTemplate t, double a, double b) =>
|
||||
Math.Clamp((int)Math.Floor((Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
|
||||
|
||||
private static int RowHigh(FastPolyTemplate t, double a, double b) =>
|
||||
Math.Clamp((int)Math.Floor((Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared, immutable grid geometry for <see cref="FastPoly"/>; the grid is defined
|
||||
/// relative to the shape's own local coordinates, so translated instances reuse it.
|
||||
/// Stamp/Seen are mutable single-threaded scratch for the edge-walk dedupe.
|
||||
/// </summary>
|
||||
internal sealed class FastPolyTemplate
|
||||
{
|
||||
public readonly double[] X;
|
||||
public readonly double[] Y;
|
||||
public readonly int Count;
|
||||
public readonly double MinX;
|
||||
public readonly double MinY;
|
||||
public readonly double MaxX;
|
||||
public readonly double MaxY;
|
||||
|
||||
public readonly double CellSize;
|
||||
|
||||
public readonly int Cols;
|
||||
public readonly int Rows;
|
||||
public readonly int[] Head;
|
||||
|
||||
/// <summary>
|
||||
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
|
||||
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
|
||||
/// chains and silently drop edges from the walk.
|
||||
/// </summary>
|
||||
public readonly int[] NodeEdge;
|
||||
|
||||
public readonly int[] NodeNext;
|
||||
public readonly int NodeCount;
|
||||
|
||||
public int Stamp;
|
||||
public readonly int[] Seen;
|
||||
|
||||
private FastPolyTemplate(
|
||||
double[] x,
|
||||
double[] y,
|
||||
int count,
|
||||
double minX,
|
||||
double minY,
|
||||
double maxX,
|
||||
double maxY
|
||||
)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Count = count;
|
||||
MinX = minX;
|
||||
MinY = minY;
|
||||
MaxX = maxX;
|
||||
MaxY = maxY;
|
||||
Seen = new int[count];
|
||||
|
||||
var extentX = Math.Max(maxX - minX, 1e-9);
|
||||
var extentY = Math.Max(maxY - minY, 1e-9);
|
||||
CellSize = Math.Max(Math.Max(extentX, extentY) / 16.0, 1e-9);
|
||||
Cols = Math.Clamp((int)Math.Ceiling(extentX / CellSize) + 1, 1, 48);
|
||||
Rows = Math.Clamp((int)Math.Ceiling(extentY / CellSize) + 1, 1, 48);
|
||||
Head = new int[Cols * Rows];
|
||||
Array.Fill(Head, -1);
|
||||
|
||||
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
|
||||
var cellsPerEdge = new int[count];
|
||||
var total = 0;
|
||||
for (var e = 0; e < count; e++)
|
||||
{
|
||||
var i2 = (e + 1) % count;
|
||||
var c0 = ClampCol(Math.Min(x[e], x[i2]) - minX);
|
||||
var c1 = ClampCol(Math.Max(x[e], x[i2]) - minX);
|
||||
var r0 = ClampRow(Math.Min(y[e], y[i2]) - minY);
|
||||
var r1 = ClampRow(Math.Max(y[e], y[i2]) - minY);
|
||||
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
|
||||
total += cellsPerEdge[e];
|
||||
}
|
||||
NodeEdge = new int[total];
|
||||
NodeNext = new int[total];
|
||||
var node = 0;
|
||||
for (var e = 0; e < count; e++)
|
||||
{
|
||||
var i2 = (e + 1) % count;
|
||||
var c0 = ClampCol(Math.Min(x[e], x[i2]) - minX);
|
||||
var c1 = ClampCol(Math.Max(x[e], x[i2]) - minX);
|
||||
var r0 = ClampRow(Math.Min(y[e], y[i2]) - minY);
|
||||
var r1 = ClampRow(Math.Max(y[e], y[i2]) - minY);
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
{
|
||||
var cell = r * Cols + c;
|
||||
NodeEdge[node] = e;
|
||||
NodeNext[node] = Head[cell];
|
||||
Head[cell] = node;
|
||||
node++;
|
||||
}
|
||||
}
|
||||
NodeCount = node;
|
||||
}
|
||||
|
||||
private int ClampCol(double dx) =>
|
||||
Math.Clamp((int)Math.Floor(dx / CellSize), 0, Cols - 1);
|
||||
|
||||
private int ClampRow(double dy) =>
|
||||
Math.Clamp((int)Math.Floor(dy / CellSize), 0, Rows - 1);
|
||||
|
||||
public static FastPolyTemplate? Build(Polygon polygon)
|
||||
{
|
||||
var vertices = polygon.Vertices;
|
||||
var n = vertices.Count;
|
||||
if (n >= 2 && vertices[0].Equals(vertices[n - 1]))
|
||||
n--;
|
||||
if (n < 3)
|
||||
return null;
|
||||
var xs = new double[n];
|
||||
var ys = new double[n];
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var vx = vertices[i].X;
|
||||
var vy = vertices[i].Y;
|
||||
xs[i] = vx;
|
||||
ys[i] = vy;
|
||||
if (vx < minX)
|
||||
minX = vx;
|
||||
if (vx > maxX)
|
||||
maxX = vx;
|
||||
if (vy < minY)
|
||||
minY = vy;
|
||||
if (vy > maxY)
|
||||
maxY = vy;
|
||||
}
|
||||
return new FastPolyTemplate(xs, ys, n, minX, minY, maxX, maxY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Whole-job decision layer: which stock the next sheet uses, the order parts are
|
||||
/// demanded in, when a sheet is finished, and when the job stops. Every placement
|
||||
/// inside a sheet comes from <see cref="SheetPacker"/>; nothing here delegates to a
|
||||
/// built-in engine, nester, filler, or runner.
|
||||
/// </summary>
|
||||
internal sealed class JobSolver
|
||||
{
|
||||
private NestJobResultBuilder _builder = null!;
|
||||
private readonly NestJob _job;
|
||||
private readonly PartPreparation _prep;
|
||||
private int _sheetCount;
|
||||
|
||||
public JobSolver(NestJob job, PartPreparation prep)
|
||||
{
|
||||
_job = job;
|
||||
_prep = prep;
|
||||
}
|
||||
|
||||
internal static bool Diagnostics { get; set; }
|
||||
private static bool _diag => Diagnostics;
|
||||
|
||||
private void Diag(string message)
|
||||
{
|
||||
if (_diag)
|
||||
Console.Error.WriteLine(
|
||||
$"[qwen] sheets={_sheetCount} placed={_job.Parts.Sum(p => _builder.Placed(p.Id))} " +
|
||||
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
|
||||
$"gc2={GC.CollectionCount(2)} {message}"
|
||||
);
|
||||
}
|
||||
|
||||
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
|
||||
{
|
||||
_builder = new NestJobResultBuilder(_job, progress);
|
||||
var reason = NestJobStopReason.Completed;
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var outstanding = OutstandingDemands();
|
||||
if (outstanding.Count == 0)
|
||||
{
|
||||
reason = _builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
if (_job.Options.MaxPlates is int cap && _sheetCount >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var attempt = BestNextSheet(outstanding, progress, token);
|
||||
Diag($"nextSheet -> {(attempt == null ? "none" : $"stock {_job.Plates[attempt.StockIndex].Id} placed {attempt.Packer.Placed.Count}")}");
|
||||
if (attempt == null)
|
||||
{
|
||||
reason = AnyStockAvailable()
|
||||
? NestJobStopReason.NoPlacementFound
|
||||
: NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
|
||||
CommitSheet(attempt.Packer);
|
||||
}
|
||||
|
||||
return _builder.Build(reason);
|
||||
}
|
||||
|
||||
private List<PartModel> OutstandingDemands()
|
||||
{
|
||||
var demands = new List<PartModel>();
|
||||
foreach (var model in _prep.Models)
|
||||
if ((model.Quantity - _builder.Placed(model.Id)) > 0)
|
||||
demands.Add(model);
|
||||
// This engine's own ordering: priority first, then the tallest-then-largest
|
||||
// part first (a part's thinnest orientation extent), then id for determinism.
|
||||
demands.Sort(
|
||||
(a, b) =>
|
||||
{
|
||||
var byPriority = a.Priority.CompareTo(b.Priority);
|
||||
if (byPriority != 0)
|
||||
return byPriority;
|
||||
if (DemandOrderMode == 1)
|
||||
{
|
||||
var byArea = b.Area.CompareTo(a.Area);
|
||||
if (byArea != 0)
|
||||
return byArea;
|
||||
}
|
||||
else if (DemandOrderMode == 2)
|
||||
{
|
||||
// Biggest footprint first (worst-case largest extent, descending).
|
||||
var byMaxSpan = MaximumMaxSpan(b).CompareTo(MaximumMaxSpan(a));
|
||||
if (byMaxSpan != 0)
|
||||
return -byMaxSpan;
|
||||
var byArea2 = b.Area.CompareTo(a.Area);
|
||||
if (byArea2 != 0)
|
||||
return byArea2;
|
||||
}
|
||||
else
|
||||
{
|
||||
var bySpan = MinimumMaxSpan(b).CompareTo(MinimumMaxSpan(a));
|
||||
if (bySpan != 0)
|
||||
return bySpan;
|
||||
var byArea = b.Area.CompareTo(a.Area);
|
||||
if (byArea != 0)
|
||||
return byArea;
|
||||
}
|
||||
return string.CompareOrdinal(a.Id, b.Id);
|
||||
}
|
||||
);
|
||||
return demands;
|
||||
}
|
||||
|
||||
private double MinimumMaxSpan(PartModel model)
|
||||
{
|
||||
if (!_minimumSpan.TryGetValue(model.Id, out var span))
|
||||
{
|
||||
span = double.MaxValue;
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = _prep.Oriented(model, angle, 0);
|
||||
var worst = Math.Max(orientation.Width, orientation.Height);
|
||||
if (worst < span)
|
||||
span = worst;
|
||||
}
|
||||
_minimumSpan[model.Id] = span;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, double> _minimumSpan = new(StringComparer.Ordinal);
|
||||
|
||||
private double MaximumMaxSpan(PartModel model)
|
||||
{
|
||||
if (!_maximumSpan.TryGetValue(model.Id, out var span))
|
||||
{
|
||||
span = 0;
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = _prep.Oriented(model, angle, 0);
|
||||
var worst = Math.Max(orientation.Width, orientation.Height);
|
||||
if (worst > span)
|
||||
span = worst;
|
||||
}
|
||||
_maximumSpan[model.Id] = span;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, double> _maximumSpan = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Packs every available stock size independently and commits the best trial:
|
||||
/// most instances first, then highest priority coverage, then the smallest sheet
|
||||
/// area (the cost function the benchmark scores), then input order.
|
||||
/// </summary>
|
||||
private SheetAttempt? BestNextSheet(
|
||||
List<PartModel> outstanding,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
SheetAttempt? best = null;
|
||||
TrialScore bestScore = default;
|
||||
|
||||
for (var index = 0; index < _job.Plates.Count; index++)
|
||||
{
|
||||
var stock = _job.Plates[index];
|
||||
if (stock.Quantity is int quantity && _builder.SheetsUsed(stock) >= quantity)
|
||||
continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
progress?.Report(
|
||||
new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate,
|
||||
stock.Id,
|
||||
_sheetCount,
|
||||
_sheetCount,
|
||||
_job.Parts.Sum(p => _builder.Placed(p.Id))
|
||||
)
|
||||
);
|
||||
|
||||
var packer = SheetPacker.Create(stock, _prep, index);
|
||||
FillSheet(packer, outstanding, token);
|
||||
if (packer.Placed.Count == 0)
|
||||
continue;
|
||||
|
||||
var score = ScoreTrial(packer);
|
||||
bool Better(TrialScore s)
|
||||
{
|
||||
if (CostFirstScoring)
|
||||
{
|
||||
// Benchmark cost is total plate AREA, so prefer the trial that
|
||||
// delivers the cheapest material per unit of part area placed;
|
||||
// priority coverage still outranks, and count breaks cost ties.
|
||||
if (best == null)
|
||||
return true;
|
||||
if (s.priorityHits != bestScore.priorityHits)
|
||||
return s.priorityHits > bestScore.priorityHits;
|
||||
if (Math.Abs(s.costPerArea - bestScore.costPerArea) > 1e-9)
|
||||
return s.costPerArea < bestScore.costPerArea;
|
||||
if (s.count != bestScore.count)
|
||||
return s.count > bestScore.count;
|
||||
return s.area < bestScore.area;
|
||||
}
|
||||
return best == null
|
||||
|| s.count > bestScore.count
|
||||
|| (s.count == bestScore.count && s.priorityHits > bestScore.priorityHits)
|
||||
|| (
|
||||
s.count == bestScore.count
|
||||
&& s.priorityHits == bestScore.priorityHits
|
||||
&& s.area < bestScore.area
|
||||
);
|
||||
}
|
||||
if (Better(score))
|
||||
{
|
||||
best = new SheetAttempt(packer, index);
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This engine's fill policy for one sheet: walk the demand order and drain each
|
||||
/// requirement greedily; a requirement that cannot place any more instances is
|
||||
/// skipped (never aborts the sheet) and retried on the next sheet. Consumes a
|
||||
/// local copy of demand - losing this trial must not change job state.
|
||||
/// </summary>
|
||||
private void FillSheet(SheetPacker packer, List<PartModel> outstanding, CancellationToken token)
|
||||
{
|
||||
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var model in outstanding)
|
||||
available[model.Id] = (model.Quantity - _builder.Placed(model.Id));
|
||||
|
||||
// One drain pass per requirement, in demand order. Gap-filling retries are
|
||||
// deliberately NOT an unbounded loop: a sheet's failed-insert scans get more
|
||||
// expensive as it fills, so an unbounded retry loop blows the benchmark's
|
||||
// 5-minute wall (observed 2-3x on a 69-drawing job). Pass two runs only with
|
||||
// the explicit retry budget below.
|
||||
foreach (var model in outstanding)
|
||||
{
|
||||
if (available[model.Id] <= 0)
|
||||
continue;
|
||||
if (!packer.CanEverFit(model))
|
||||
continue;
|
||||
while (available[model.Id] > 0 && !packer.IsFull)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!packer.TryInsert(model, out _))
|
||||
break;
|
||||
available[model.Id]--;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Count failed insertion sweeps, independent of machine speed and load.
|
||||
// Successful insertions are bounded by the outstanding quantities.
|
||||
var retries = GapFillFailedInsertBudget;
|
||||
var retryAgain = true;
|
||||
while (retryAgain && retries > 0)
|
||||
{
|
||||
retryAgain = false;
|
||||
foreach (var model in outstanding)
|
||||
{
|
||||
if (available[model.Id] <= 0 || packer.IsFull)
|
||||
continue;
|
||||
if (retries <= 0)
|
||||
break;
|
||||
while (available[model.Id] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!packer.TryInsert(model, out _))
|
||||
{
|
||||
retries--;
|
||||
break;
|
||||
}
|
||||
available[model.Id]--;
|
||||
retryAgain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demand ordering within the priority sort: 1 = largest material area first
|
||||
/// (measured best: big parts establish the sheet skeleton, small ones then fill
|
||||
/// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint
|
||||
/// first, 0 = smallest worst-case extent first (original).
|
||||
/// </summary>
|
||||
internal static int DemandOrderMode { get; set; } = 1;
|
||||
|
||||
/// <summary>Maximum failed insertion sweeps per sheet gap-fill pass.</summary>
|
||||
internal static int GapFillFailedInsertBudget { get; set; } = 8;
|
||||
|
||||
/// <summary>Trial-sheet metrics; costPerArea = net sheet area / material area placed.</summary>
|
||||
private readonly record struct TrialScore(
|
||||
int count,
|
||||
int priorityHits,
|
||||
double area,
|
||||
double costPerArea
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost
|
||||
/// function (total plate area); count-first is the conservative fill policy.
|
||||
/// Internal setting permits controlled A/B tests.
|
||||
/// </summary>
|
||||
internal static bool CostFirstScoring { get; set; } = true;
|
||||
|
||||
private TrialScore ScoreTrial(SheetPacker packer)
|
||||
{
|
||||
var count = packer.Placed.Count;
|
||||
var bestPriority = int.MaxValue;
|
||||
foreach (var placed in packer.Placed)
|
||||
if (placed.Model.Priority < bestPriority)
|
||||
bestPriority = placed.Model.Priority;
|
||||
var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority);
|
||||
var area = NestJobCost.NetSheetArea(_job, new NestJobPlateResult(0, packer.Stock,
|
||||
packer.Placed.Select(p => new NestJobPlacement(p.Model.Id, 0, p.X, p.Y, p.Orientation.Angle))));
|
||||
var placedArea = 0.0;
|
||||
foreach (var placed in packer.Placed)
|
||||
placedArea += placed.Model.Area;
|
||||
var costPerArea = placedArea > 1e-9 ? area / placedArea : double.MaxValue;
|
||||
return new TrialScore(count, priorityHits, area, costPerArea);
|
||||
}
|
||||
|
||||
private void CommitSheet(SheetPacker packer)
|
||||
{
|
||||
_builder.AddSheet(packer.Stock, packer.Placed.Select(p =>
|
||||
(p.Model.Id, p.X, p.Y, p.Orientation.Angle)));
|
||||
_sheetCount++;
|
||||
}
|
||||
|
||||
private bool AnyStockAvailable()
|
||||
{
|
||||
foreach (var stock in _job.Plates)
|
||||
if (stock.Quantity is null || _builder.SheetsUsed(stock) < stock.Quantity.Value)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
/// <summary>
|
||||
/// A job requirement prepared once per solve: snapshot motions rebuilt into an owned
|
||||
/// closed contour topology (perimeter + cutouts; rapid/layer-mark geometry dropped),
|
||||
/// flattened collision polygons, and material area.
|
||||
/// </summary>
|
||||
internal sealed class PartModel
|
||||
{
|
||||
private PartModel(
|
||||
string id,
|
||||
int quantity,
|
||||
int priority,
|
||||
RotationPolicy rotation,
|
||||
ShapeProfile profile,
|
||||
Shape perimeterShape,
|
||||
List<Shape> cutoutShapes,
|
||||
double area
|
||||
)
|
||||
{
|
||||
Id = id;
|
||||
Quantity = quantity;
|
||||
Priority = priority;
|
||||
Rotation = rotation;
|
||||
Profile = profile;
|
||||
PerimeterShape = perimeterShape;
|
||||
CutoutShapes = cutoutShapes;
|
||||
Area = area;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public int Quantity { get; }
|
||||
public int Priority { get; }
|
||||
public RotationPolicy Rotation { get; }
|
||||
|
||||
/// <summary>Closed contour topology (perimeter CCW, cutouts) used for region offsets.</summary>
|
||||
public ShapeProfile Profile { get; }
|
||||
|
||||
/// <summary>Analytic closed perimeter (arcs preserved) for conservative flattening.</summary>
|
||||
public Shape PerimeterShape { get; }
|
||||
|
||||
public List<Shape> CutoutShapes { get; }
|
||||
|
||||
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
|
||||
public double Area { get; }
|
||||
internal List<double>? Angles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER
|
||||
/// than NestTolerances.ValidationOutline (0.001): any chord cuts the cap off a
|
||||
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
|
||||
/// subset of the validator's material in notched regions and admits real spacing
|
||||
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
|
||||
/// every engine polygon contains the validator's, so a cleared gate is conservative.
|
||||
/// </summary>
|
||||
public const double CollisionTolerance = 0.0005;
|
||||
|
||||
/// <summary>
|
||||
/// Returns null when the snapshot has no usable closed contour - such a part can
|
||||
/// never be placed and is reported unplaced rather than failing the whole job.
|
||||
/// </summary>
|
||||
public static PartModel? TryCreate(NestJobPart part)
|
||||
{
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
if (geometry == null || geometry.MaterialArea <= Tolerance.Epsilon) return null;
|
||||
// Read normalizes winding before the collision and offset preparation below.
|
||||
return new PartModel(part.Id, part.Quantity, part.Priority, part.Rotation,
|
||||
geometry.Profile, geometry.Perimeter, geometry.Cutouts.ToList(), geometry.MaterialArea);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One part contour rotated about the snapshot origin - exactly the frame a
|
||||
/// <see cref="NestJobPlacement"/> produces (rotate, then translate by X/Y). Bounds,
|
||||
/// convex hull, and the spacing-inflated outline are computed once and reused.
|
||||
/// </summary>
|
||||
internal sealed class OrientationModel
|
||||
{
|
||||
internal OrientationModel(
|
||||
double angle,
|
||||
Polygon perimeter,
|
||||
List<Polygon> holes,
|
||||
Polygon? inflatedPerimeter,
|
||||
List<Polygon> inflatedHoles,
|
||||
double spacing
|
||||
)
|
||||
{
|
||||
Angle = angle;
|
||||
Perimeter = perimeter;
|
||||
Holes = holes;
|
||||
InflatedPerimeter = inflatedPerimeter;
|
||||
InflatedHoles = inflatedHoles;
|
||||
Spacing = spacing;
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
foreach (var v in perimeter.Vertices)
|
||||
{
|
||||
if (v.X < minX)
|
||||
minX = v.X;
|
||||
if (v.X > maxX)
|
||||
maxX = v.X;
|
||||
if (v.Y < minY)
|
||||
minY = v.Y;
|
||||
if (v.Y > maxY)
|
||||
maxY = v.Y;
|
||||
}
|
||||
MinX = minX;
|
||||
MinY = minY;
|
||||
MaxX = maxX;
|
||||
MaxY = maxY;
|
||||
|
||||
var hullPoints = new List<Vector>();
|
||||
try
|
||||
{
|
||||
var hull = ConvexHull.Compute(perimeter.Vertices);
|
||||
foreach (var v in hull.Vertices)
|
||||
{
|
||||
if (hullPoints.Count > 0 && v.Equals(hullPoints[^1]))
|
||||
continue;
|
||||
hullPoints.Add(v);
|
||||
}
|
||||
if (hullPoints.Count > 1 && hullPoints[0].Equals(hullPoints[^1]))
|
||||
hullPoints.RemoveAt(hullPoints.Count - 1);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
hullPoints.Clear();
|
||||
}
|
||||
Hull = hullPoints.Count >= 3 ? hullPoints : perimeter.Vertices;
|
||||
|
||||
// True when the flattened perimeter is itself convex (no concavities) and the
|
||||
// part has no cutouts: for two such parts the convex NFP is EXACT - material
|
||||
// equals hull - so an anchor inside it is forbidden with no material test.
|
||||
var convex = holes.Count == 0;
|
||||
if (convex)
|
||||
{
|
||||
var verts = perimeter.Vertices;
|
||||
var m = verts.Count;
|
||||
if (m > 2 && verts[0].Equals(verts[m - 1]))
|
||||
m--;
|
||||
for (var i = 0; i < m && convex; i++)
|
||||
{
|
||||
var ax = verts[i].X;
|
||||
var ay = verts[i].Y;
|
||||
var bx = verts[(i + 1) % m].X;
|
||||
var by = verts[(i + 1) % m].Y;
|
||||
var cx = verts[(i + 2) % m].X;
|
||||
var cy = verts[(i + 2) % m].Y;
|
||||
if ((bx - ax) * (cy - by) - (by - ay) * (cx - bx) < -1e-9)
|
||||
convex = false;
|
||||
}
|
||||
}
|
||||
IsConvexSolid = convex;
|
||||
}
|
||||
|
||||
/// <summary>No cutouts and a convex perimeter: material equals hull.</summary>
|
||||
public bool IsConvexSolid { get; }
|
||||
|
||||
public double Angle { get; }
|
||||
|
||||
/// <summary>Circumscribed flattened perimeter in the rotated frame (pre-translation).</summary>
|
||||
public Polygon Perimeter { get; }
|
||||
|
||||
public List<Polygon> Holes { get; }
|
||||
|
||||
/// <summary>Material outline inflated by <see cref="Spacing"/> (null when spacing is zero).</summary>
|
||||
public Polygon? InflatedPerimeter { get; }
|
||||
|
||||
/// <summary>Cutouts shrunk by <see cref="Spacing"/>; holes that close up are dropped (treated solid).</summary>
|
||||
public List<Polygon> InflatedHoles { get; }
|
||||
|
||||
public double Spacing { get; }
|
||||
|
||||
public double MinX { get; }
|
||||
public double MinY { get; }
|
||||
public double MaxX { get; }
|
||||
public double MaxY { get; }
|
||||
public double Width => MaxX - MinX;
|
||||
public double Height => MaxY - MinY;
|
||||
|
||||
/// <summary>Convex hull of the perimeter (open vertex list, at least 3 points).</summary>
|
||||
public List<Vector> Hull { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fast clearance outline of the raw perimeter in this orientation's local frame
|
||||
/// (lazily built; translated per anchor in O(1) via <see cref="FastPoly.Translated"/>).
|
||||
/// </summary>
|
||||
public FastPoly? PerimeterFast => _perimeterFast ??= FastPoly.From(Perimeter);
|
||||
|
||||
private FastPoly? _perimeterFast;
|
||||
|
||||
/// <summary>
|
||||
/// Fast clearance outline of the gate material (spacing-inflated when positive) in
|
||||
/// this orientation's local frame.
|
||||
/// </summary>
|
||||
public FastPoly? GateFast =>
|
||||
_gateFast ??= FastPoly.From(InflatedPerimeter ?? Perimeter);
|
||||
|
||||
private FastPoly? _gateFast;
|
||||
|
||||
/// <summary>
|
||||
/// Cached triangulation of the raw material (perimeter + holes) in this
|
||||
/// orientation's local frame for the allocation-free exact gate.
|
||||
/// </summary>
|
||||
public TriSet? MaterialTris => _materialTris ??= TriSet.Build(Perimeter, Holes);
|
||||
|
||||
private TriSet? _materialTris;
|
||||
|
||||
/// <summary>
|
||||
/// Cached triangulation of the gate material (spacing-inflated perimeter with
|
||||
/// shrunk holes) in this orientation's local frame.
|
||||
/// </summary>
|
||||
public TriSet? GateTris =>
|
||||
_gateTris ??= TriSet.Build(InflatedPerimeter ?? Perimeter, InflatedPerimeter != null ? InflatedHoles : Holes);
|
||||
|
||||
private TriSet? _gateTris;
|
||||
}
|
||||
|
||||
/// <summary>Builds and caches per-(part, orientation, spacing) geometry for one engine run.</summary>
|
||||
internal sealed class PartPreparation
|
||||
{
|
||||
private readonly List<PartModel> models = new();
|
||||
private readonly Dictionary<string, int> indexById = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
|
||||
|
||||
/// <summary>
|
||||
/// Cross-packer memo of exact material overlap: (placed orientation, placed anchor,
|
||||
/// candidate orientation, candidate anchor) -> overlap. Sheet trials rebuild greedy
|
||||
/// placement deterministically, so identical world poses recur across trials and
|
||||
/// across sheets; the memo collapses the repeated polygon-clipping work. Bounded so
|
||||
/// it can never grow unboundedly on pathological jobs.
|
||||
/// </summary>
|
||||
private readonly Dictionary<OverlapKey, bool> overlaps = new();
|
||||
|
||||
internal sealed class OverlapKey : IEquatable<OverlapKey>
|
||||
{
|
||||
private readonly int _placedHash;
|
||||
private readonly long _px;
|
||||
private readonly long _py;
|
||||
private readonly int _candHash;
|
||||
private readonly long _cx;
|
||||
private readonly long _cy;
|
||||
|
||||
public OverlapKey(int placedHash, double px, double py, int candHash, double cx, double cy)
|
||||
{
|
||||
_placedHash = placedHash;
|
||||
_px = (long)Math.Round(px * 1e6);
|
||||
_py = (long)Math.Round(py * 1e6);
|
||||
_candHash = candHash;
|
||||
_cx = (long)Math.Round(cx * 1e6);
|
||||
_cy = (long)Math.Round(cy * 1e6);
|
||||
}
|
||||
|
||||
public bool Equals(OverlapKey? other) =>
|
||||
other != null
|
||||
&& _placedHash == other._placedHash
|
||||
&& _px == other._px
|
||||
&& _py == other._py
|
||||
&& _candHash == other._candHash
|
||||
&& _cx == other._cx
|
||||
&& _cy == other._cy;
|
||||
|
||||
public override bool Equals(object? obj) => Equals(obj as OverlapKey);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = _placedHash;
|
||||
hash = unchecked(hash * 397 + _px.GetHashCode());
|
||||
hash = unchecked(hash * 397 + _py.GetHashCode());
|
||||
hash = unchecked(hash * 397 + _candHash);
|
||||
hash = unchecked(hash * 397 + _cx.GetHashCode());
|
||||
hash = unchecked(hash * 397 + _cy.GetHashCode());
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private const int OverlapMemoCap = 500_000;
|
||||
|
||||
public bool MaterialOverlapMemo(
|
||||
OrientationModel placed,
|
||||
double placedX,
|
||||
double placedY,
|
||||
OrientationModel candidate,
|
||||
double candidateX,
|
||||
double candidateY,
|
||||
Func<bool> compute
|
||||
)
|
||||
{
|
||||
var key = new OverlapKey(
|
||||
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(placed),
|
||||
placedX,
|
||||
placedY,
|
||||
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(candidate),
|
||||
candidateX,
|
||||
candidateY
|
||||
);
|
||||
if (overlaps.TryGetValue(key, out var known))
|
||||
return known;
|
||||
if (overlaps.Count >= OverlapMemoCap)
|
||||
overlaps.Clear();
|
||||
var value = compute();
|
||||
overlaps[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<PartModel> Models => models;
|
||||
|
||||
public PartPreparation(IReadOnlyList<NestJobPart> parts)
|
||||
{
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var model = PartModel.TryCreate(part);
|
||||
if (model == null)
|
||||
{
|
||||
InvalidIds.Add(part.Id);
|
||||
continue;
|
||||
}
|
||||
indexById[model.Id] = models.Count;
|
||||
models.Add(model);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requirements whose snapshot geometry could not be interpreted at all.</summary>
|
||||
public List<string> InvalidIds { get; } = new();
|
||||
|
||||
public bool TryGetModel(string partId, out PartModel model)
|
||||
{
|
||||
model = null!;
|
||||
if (!indexById.TryGetValue(partId, out var index))
|
||||
return false;
|
||||
model = models[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
public OrientationModel Oriented(PartModel model, double angle, double spacing)
|
||||
{
|
||||
// Round keys so policy-equivalent angles (0 vs 2pi) share one cached orientation.
|
||||
var key = (model.Id, Math.Round(angle, 9), Math.Round(spacing, 9));
|
||||
if (orientations.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
var perimeterShape = (Shape)model.PerimeterShape.Clone();
|
||||
perimeterShape.Rotate(angle);
|
||||
var perimeter = perimeterShape.ToPolygonWithTolerance(
|
||||
PartModel.CollisionTolerance,
|
||||
circumscribe: true
|
||||
);
|
||||
var holes = new List<Polygon>(model.CutoutShapes.Count);
|
||||
foreach (var cutout in model.CutoutShapes)
|
||||
{
|
||||
var shape = (Shape)cutout.Clone();
|
||||
shape.Rotate(angle);
|
||||
holes.Add(
|
||||
shape.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
|
||||
);
|
||||
}
|
||||
|
||||
Polygon? inflated = null;
|
||||
var inflatedHoles = new List<Polygon>();
|
||||
if (spacing > Tolerance.Epsilon)
|
||||
{
|
||||
// Conservative (circumscribed, padded) region offset: a superset of the
|
||||
// validator's inflation, so accepted clearances never fall short. The
|
||||
// offset commutes with rotation, so inflate the unrotated profile once and
|
||||
// rotate the result into this orientation's frame - an unrotated inflation
|
||||
// would test the candidate against the material of a different angle.
|
||||
var region = ClipperBridge.Offset(model.Profile, spacing, 0.02, circumscribe: true);
|
||||
var outer = region.LargestOuter();
|
||||
if (outer != null)
|
||||
{
|
||||
outer.Rotate(angle);
|
||||
outer.UpdateBounds();
|
||||
inflated = outer;
|
||||
}
|
||||
foreach (var hole in region.Holes)
|
||||
if (hole != null)
|
||||
{
|
||||
hole.Rotate(angle);
|
||||
hole.UpdateBounds();
|
||||
inflatedHoles.Add(hole);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new OrientationModel(angle, perimeter, holes, inflated, inflatedHoles, spacing);
|
||||
orientations[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legal orientations for a requirement: exactly the policy angles when the policy
|
||||
/// enumerates them, otherwise 0/90/180/270 degrees plus the minimum-area bounding
|
||||
/// rectangle angle (rotating-calipers), with 180-degree equivalents included.
|
||||
/// </summary>
|
||||
public static List<double> CandidateAngles(PartModel model)
|
||||
{
|
||||
if (model.Angles != null) return model.Angles;
|
||||
var angles = model.Rotation.Kind == RotationPolicyKind.Automatic
|
||||
? RotationCandidates.ForShape(model.Rotation, model.PerimeterShape)
|
||||
: model.Rotation.EnumerateAngles(maxSamples: 4000);
|
||||
// Perimeter symmetry does not establish symmetry of the cutouts.
|
||||
return model.Angles = (model.CutoutShapes.Count == 0
|
||||
? RotationCandidates.DistinctOutlines(model.PerimeterShape, angles)
|
||||
: angles).ToList();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
/// <summary>A committed placement: model, orientation, and origin position on the sheet.</summary>
|
||||
internal readonly struct PlacedPart
|
||||
{
|
||||
public PlacedPart(PartModel model, OrientationModel orientation, double x, double y)
|
||||
{
|
||||
Model = model;
|
||||
Orientation = orientation;
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
|
||||
public PartModel Model { get; }
|
||||
public OrientationModel Orientation { get; }
|
||||
public double X { get; }
|
||||
public double Y { get; }
|
||||
}
|
||||
|
||||
internal readonly struct PlacementResult
|
||||
{
|
||||
public PlacementResult(PlacedPart part)
|
||||
{
|
||||
Part = part;
|
||||
}
|
||||
|
||||
public PlacedPart Part { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sheet packed by this engine's own algorithm: bottom-left greedy insertion of
|
||||
/// convex NFP corner candidates.
|
||||
/// <para>
|
||||
/// For each candidate orientation the packer builds a convex No-Fit-Polygon per
|
||||
/// already-placed part as placedHull (+) disk(spacing) (+) reflect(candidateHull) -
|
||||
/// a superset of the true NFP because hulls ignore concavities and cutouts, so an
|
||||
/// anchor outside every NFP plus inside the anchor work-box is always legal ("strict"
|
||||
/// certification). An anchor inside an NFP is still accepted when the exact material
|
||||
/// gate says the parts clear: that gate inflates the placed part's material by the
|
||||
/// spacing (holes shrunk, closed holes treated solid) and tests it against the
|
||||
/// candidate's raw material with holes subtracted - the same inflation rule the
|
||||
/// benchmark validator uses, so interlocking concave parts are recovered without ever
|
||||
/// accepting an overlap or a spacing violation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Candidate anchors are the corner points of the feasible region: the four anchor
|
||||
/// work-box corners, every NFP vertex, and every NFP-edge/box-line crossing (slides).
|
||||
/// Candidates are tried in ascending bottom-left order and the first legal one wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SheetPacker
|
||||
{
|
||||
private const int MaxHullVertices = 40;
|
||||
|
||||
private readonly double _workLeft;
|
||||
private readonly double _workBottom;
|
||||
private readonly double _workRight;
|
||||
private readonly double _workTop;
|
||||
|
||||
// Per-placed-part caches (indexed by placement order).
|
||||
private readonly List<Bounds> _inflatedBounds = new();
|
||||
private readonly List<(Polygon Perimeter, List<Polygon> Holes)> _placedGate = new();
|
||||
|
||||
/// <summary>
|
||||
/// Fast-path outlines of the placed parts' gate material in world coordinates,
|
||||
/// parallel to <see cref="_placedGate"/> (O(1) translation of the shared per-
|
||||
/// orientation template). A <see cref="FastPoly.Clears"/> hit certifies the two
|
||||
/// outer shells - hence both materials - are clear and skips the exact
|
||||
/// <see cref="Collision"/> gate, which at fine flattening triangulates thousands
|
||||
/// of edges per call. Null when the outline has no usable ring.
|
||||
/// </summary>
|
||||
private readonly List<FastPoly?> _placedGateFast = new();
|
||||
|
||||
// NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
|
||||
private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
|
||||
private readonly Dictionary<OrientationModel, int> _orientationIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-orientation cache of NFP/NFP valley anchors (anchors touching two placed
|
||||
/// parts at once). A committed part's NFP never changes and <see cref="Placed"/>
|
||||
/// only grows, so each (i, j) pair is intersected exactly once per orientation
|
||||
/// instead of once per candidate enumeration - the re-sweep was the dominant cost
|
||||
/// on crowded sheets (O(placed^2 * edges^2) per insert attempt).
|
||||
/// </summary>
|
||||
private sealed class ValleyCache
|
||||
{
|
||||
public int BuiltThrough;
|
||||
public readonly List<(double X, double Y)> Valleys = new();
|
||||
}
|
||||
|
||||
private readonly Dictionary<OrientationModel, ValleyCache> _valleyCaches = new();
|
||||
private readonly Dictionary<OrientationModel, ConvexContour> _reflectedHulls = new();
|
||||
private readonly Dictionary<int, ConvexContour> _placedHullDisk = new();
|
||||
private ConvexContour? _disk;
|
||||
|
||||
// Uniform spatial grid over placed parts' inflated bounds: IsLegal only tests the
|
||||
// parts whose cells touch the candidate's cells, so legality stays near-constant
|
||||
// as a sheet fills instead of scanning every placed part.
|
||||
private readonly double _cellSize;
|
||||
private readonly int _gridCols;
|
||||
private readonly int _gridRows;
|
||||
private readonly List<int>[] _grid;
|
||||
|
||||
private SheetPacker(NestPlateStock stock, PartPreparation prep, int stockIndex)
|
||||
{
|
||||
Stock = stock;
|
||||
StockIndex = stockIndex;
|
||||
Preparation = prep;
|
||||
Spacing = stock.PartSpacing;
|
||||
var work = stock.WorkArea;
|
||||
_workLeft = work.Left;
|
||||
_workBottom = work.Bottom;
|
||||
_workRight = work.Right;
|
||||
_workTop = work.Top;
|
||||
WorkWidth = _workRight - _workLeft;
|
||||
WorkHeight = _workTop - _workBottom;
|
||||
|
||||
// Cells roughly the size of a mid-range part: a candidate usually touches 2-6.
|
||||
_cellSize = System.Math.Max(1.0, System.Math.Min(WorkWidth, WorkHeight) / 12.0);
|
||||
_gridCols = System.Math.Max(1, (int)System.Math.Ceiling(WorkWidth / _cellSize));
|
||||
_gridRows = System.Math.Max(1, (int)System.Math.Ceiling(WorkHeight / _cellSize));
|
||||
_grid = new List<int>[_gridCols * _gridRows];
|
||||
for (var i = 0; i < _grid.Length; i++)
|
||||
_grid[i] = new List<int>();
|
||||
}
|
||||
|
||||
public static SheetPacker Create(NestPlateStock stock, PartPreparation prep, int stockIndex) =>
|
||||
new(stock, prep, stockIndex);
|
||||
|
||||
public NestPlateStock Stock { get; }
|
||||
public int StockIndex { get; }
|
||||
public PartPreparation Preparation { get; }
|
||||
public double Spacing { get; }
|
||||
public double WorkWidth { get; }
|
||||
public double WorkHeight { get; }
|
||||
|
||||
public List<PlacedPart> Placed { get; } = new();
|
||||
|
||||
public bool IsFull => Placed.Count >= MaxPartsPerSheet;
|
||||
|
||||
/// <summary>
|
||||
/// Safety cap on parts per sheet: real sheets never exceed this, and it bounds
|
||||
/// per-insert NFP work and the validator's area budget on pathological jobs.
|
||||
/// </summary>
|
||||
public const int MaxPartsPerSheet = 500;
|
||||
|
||||
/// <summary>True when the part's bounds can never fit this sheet in any orientation.</summary>
|
||||
public bool CanEverFit(PartModel model)
|
||||
{
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = Preparation.Oriented(model, angle, 0);
|
||||
if (Stock.Fits(orientation.Width, orientation.Height))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal long DiagInsertAttempts;
|
||||
internal long DiagCandidateChecks;
|
||||
internal long DiagGateCalls;
|
||||
internal long DiagConvexRejections;
|
||||
internal long DiagFastClears;
|
||||
internal long DiagFastNull;
|
||||
internal long DiagFastOverlapWithHoles;
|
||||
internal long DiagFastOverlaps;
|
||||
internal long DiagFastUnknowns;
|
||||
|
||||
public string DiagStats() =>
|
||||
$"inserts={DiagInsertAttempts} checks={DiagCandidateChecks} gates={DiagGateCalls} " +
|
||||
$"convexRej={DiagConvexRejections} fastClear={DiagFastClears} fastOver={DiagFastOverlaps} fastUnk={DiagFastUnknowns} fastNull={DiagFastNull} fastOverHoles={DiagFastOverlapWithHoles} triNull={DiagTriNull} triNullOut={DiagTriNullOut} triFallback={DiagTriFallback}";
|
||||
|
||||
/// <summary>
|
||||
/// Greedily insert an instance: best (bottom-left) legal corner over all candidate
|
||||
/// orientations. Returns false (and changes nothing) when no legal position exists.
|
||||
/// </summary>
|
||||
public bool TryInsert(PartModel model, out PlacementResult result)
|
||||
{
|
||||
result = default;
|
||||
var bestScore = double.MaxValue;
|
||||
PlacedPart? best = null;
|
||||
DiagInsertAttempts++;
|
||||
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = Preparation.Oriented(model, angle, Spacing);
|
||||
if (orientation.Width > WorkWidth + 1e-9 || orientation.Height > WorkHeight + 1e-9)
|
||||
continue;
|
||||
|
||||
foreach (var (x, y) in OrderedCandidates(orientation))
|
||||
{
|
||||
var score = Score(orientation, x, y);
|
||||
if (score >= bestScore)
|
||||
continue; // no later candidate (same sort) can beat it
|
||||
if (!IsLegal(orientation, x, y))
|
||||
continue;
|
||||
bestScore = score;
|
||||
best = new PlacedPart(model, orientation, x, y);
|
||||
break; // first legal in ascending-score order is this orientation's best
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
return false;
|
||||
|
||||
Commit(best.Value);
|
||||
result = new PlacementResult(best.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private double Score(OrientationModel orientation, double x, double y) =>
|
||||
x + orientation.MinX + (y + orientation.MinY) * 1.0001;
|
||||
|
||||
/// <summary>
|
||||
/// Corner candidates in deterministic ascending bottom-left order: anchor work-box
|
||||
/// corners, NFP vertices, and NFP-edge/box-line crossings.
|
||||
/// </summary>
|
||||
private List<(double x, double y)> OrderedCandidates(OrientationModel orientation)
|
||||
{
|
||||
var boxLeft = _workLeft - orientation.MinX;
|
||||
var boxRight = _workRight - orientation.MaxX;
|
||||
var boxBottom = _workBottom - orientation.MinY;
|
||||
var boxTop = _workTop - orientation.MaxY;
|
||||
|
||||
var seen = new HashSet<(long, long)>();
|
||||
var candidates = new List<(double, double)>(128);
|
||||
|
||||
// Math.Clamp throws when min > max, and a part that fits the work area to
|
||||
// within floating-point noise can invert the anchor box by ~1e-14. Order the
|
||||
// bounds so a degenerate box collapses to its single legal point.
|
||||
var anchorMinX = Math.Min(boxLeft, boxRight);
|
||||
var anchorMaxX = Math.Max(boxLeft, boxRight);
|
||||
var anchorMinY = Math.Min(boxBottom, boxTop);
|
||||
var anchorMaxY = Math.Max(boxBottom, boxTop);
|
||||
|
||||
void Add(double x, double y)
|
||||
{
|
||||
if (x < anchorMinX - 1e-9 || x > anchorMaxX + 1e-9 || y < anchorMinY - 1e-9 || y > anchorMaxY + 1e-9)
|
||||
return;
|
||||
x = Math.Clamp(x, anchorMinX, anchorMaxX);
|
||||
y = Math.Clamp(y, anchorMinY, anchorMaxY);
|
||||
if (!seen.Add(((long)Math.Round(x * 1e6), (long)Math.Round(y * 1e6))))
|
||||
return;
|
||||
candidates.Add((x, y));
|
||||
}
|
||||
|
||||
Add(boxLeft, boxBottom);
|
||||
Add(boxRight, boxBottom);
|
||||
Add(boxLeft, boxTop);
|
||||
Add(boxRight, boxTop);
|
||||
|
||||
for (var i = 0; i < Placed.Count; i++)
|
||||
{
|
||||
var nfp = NfpFor(i, orientation);
|
||||
if (nfp == null)
|
||||
continue;
|
||||
var n = nfp.Count;
|
||||
for (var v = 0; v < n; v++)
|
||||
Add(nfp.X(v), nfp.Y(v));
|
||||
// Slides: NFP edges crossing the anchor box border lines.
|
||||
for (var v = 0; v < n; v++)
|
||||
{
|
||||
var ax = nfp.X(v);
|
||||
var ay = nfp.Y(v);
|
||||
var bx = nfp.X((v + 1) % n);
|
||||
var by = nfp.Y((v + 1) % n);
|
||||
CrossLine(ax, ay, bx, by, boxLeft, true, Add);
|
||||
CrossLine(ax, ay, bx, by, boxRight, true, Add);
|
||||
CrossLine(ax, ay, bx, by, boxBottom, false, Add);
|
||||
CrossLine(ax, ay, bx, by, boxTop, false, Add);
|
||||
}
|
||||
}
|
||||
|
||||
// Valleys between two neighbors: NFP/NFP edge intersections are the anchors
|
||||
// where the candidate touches two placed parts at once - the classic
|
||||
// bottom-left stable corners the single-NFP candidates cannot produce.
|
||||
foreach (var (vx, vy) in ValleysFor(orientation))
|
||||
Add(vx, vy);
|
||||
|
||||
candidates.Sort(
|
||||
(p, q) =>
|
||||
{
|
||||
var byY = p.Item2.CompareTo(q.Item2);
|
||||
return byY != 0 ? byY : p.Item1.CompareTo(q.Item1);
|
||||
}
|
||||
);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached NFP/NFP valley anchors for one orientation, extended in place with the
|
||||
/// pairs involving placements committed since the last call. Each (i, j) pair is
|
||||
/// intersected once per orientation for the packer's lifetime.
|
||||
/// </summary>
|
||||
private List<(double X, double Y)> ValleysFor(OrientationModel orientation)
|
||||
{
|
||||
if (!_valleyCaches.TryGetValue(orientation, out var cache))
|
||||
{
|
||||
cache = new ValleyCache();
|
||||
_valleyCaches[orientation] = cache;
|
||||
}
|
||||
|
||||
var count = Placed.Count;
|
||||
for (var j = cache.BuiltThrough; j < count; j++)
|
||||
{
|
||||
var nfpB = NfpFor(j, orientation);
|
||||
if (nfpB == null)
|
||||
continue;
|
||||
for (var i = 0; i < j; i++)
|
||||
{
|
||||
var nfpA = NfpFor(i, orientation);
|
||||
if (nfpA == null || !nfpA.Bounds.Intersects(nfpB.Bounds))
|
||||
continue;
|
||||
var na = nfpA.Count;
|
||||
var nb = nfpB.Count;
|
||||
for (var va = 0; va < na; va++)
|
||||
{
|
||||
var a0x = nfpA.X(va);
|
||||
var a0y = nfpA.Y(va);
|
||||
var a1x = nfpA.X((va + 1) % na);
|
||||
var a1y = nfpA.Y((va + 1) % na);
|
||||
for (var vb = 0; vb < nb; vb++)
|
||||
{
|
||||
var b0x = nfpB.X(vb);
|
||||
var b0y = nfpB.Y(vb);
|
||||
var b1x = nfpB.X((vb + 1) % nb);
|
||||
var b1y = nfpB.Y((vb + 1) % nb);
|
||||
if (
|
||||
Math.Max(a0x, a1x) < Math.Min(b0x, b1x)
|
||||
|| Math.Max(b0x, b1x) < Math.Min(a0x, a1x)
|
||||
|| Math.Max(a0y, a1y) < Math.Min(b0y, b1y)
|
||||
|| Math.Max(b0y, b1y) < Math.Min(a0y, a1y)
|
||||
)
|
||||
continue;
|
||||
var r = SegmentIntersect(
|
||||
a0x, a0y, a1x, a1y,
|
||||
b0x, b0y, b1x, b1y
|
||||
);
|
||||
if (r.HasValue)
|
||||
cache.Valleys.Add((r.Value.X, r.Value.Y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.BuiltThrough = count;
|
||||
return cache.Valleys;
|
||||
}
|
||||
|
||||
/// <summary>Proper or endpoint intersection of two segments, if any.</summary>
|
||||
private static Vector? SegmentIntersect(
|
||||
double ax,
|
||||
double ay,
|
||||
double bx,
|
||||
double by,
|
||||
double cx,
|
||||
double cy,
|
||||
double dx,
|
||||
double dy
|
||||
)
|
||||
{
|
||||
var rx = bx - ax;
|
||||
var ry = by - ay;
|
||||
var sx = dx - cx;
|
||||
var sy = dy - cy;
|
||||
var denom = rx * sy - ry * sx;
|
||||
if (Math.Abs(denom) < 1e-12)
|
||||
return null; // parallel
|
||||
var t = ((cx - ax) * sy - (cy - ay) * sx) / denom;
|
||||
var u = ((cx - ax) * ry - (cy - ay) * rx) / denom;
|
||||
if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9)
|
||||
return null;
|
||||
return new Vector(ax + t * rx, ay + t * ry);
|
||||
}
|
||||
|
||||
private static void CrossLine(
|
||||
double ax,
|
||||
double ay,
|
||||
double bx,
|
||||
double by,
|
||||
double at,
|
||||
bool vertical,
|
||||
Action<double, double> add
|
||||
)
|
||||
{
|
||||
var (ua, ub) = vertical ? (ax, bx) : (ay, by);
|
||||
if (ua == ub)
|
||||
return;
|
||||
var t = (at - ua) / (ub - ua);
|
||||
if (t < 0 || t > 1)
|
||||
return;
|
||||
var along = vertical ? ay + (by - ay) * t : ax + (bx - ax) * t;
|
||||
if (vertical)
|
||||
add(at, along);
|
||||
else
|
||||
add(along, at);
|
||||
}
|
||||
|
||||
private void Commit(PlacedPart part)
|
||||
{
|
||||
Placed.Add(part);
|
||||
var pad = Spacing;
|
||||
_inflatedBounds.Add(
|
||||
new Bounds(
|
||||
part.X + part.Orientation.MinX - pad,
|
||||
part.Y + part.Orientation.MinY - pad,
|
||||
part.X + part.Orientation.MaxX + pad,
|
||||
part.Y + part.Orientation.MaxY + pad
|
||||
)
|
||||
);
|
||||
|
||||
// Gate geometry: material inflated by spacing (holes shrunk) when positive,
|
||||
// raw material at zero spacing; already in world coordinates.
|
||||
var gatePerimeter = part.Orientation.InflatedPerimeter ?? part.Orientation.Perimeter;
|
||||
var gateHoles = part.Orientation.InflatedPerimeter != null
|
||||
? part.Orientation.InflatedHoles
|
||||
: part.Orientation.Holes;
|
||||
var worldPerimeter = (Polygon)gatePerimeter.Clone();
|
||||
worldPerimeter.Offset(part.X, part.Y);
|
||||
worldPerimeter.UpdateBounds();
|
||||
var worldHoles = new List<Polygon>(gateHoles.Count);
|
||||
foreach (var hole in gateHoles)
|
||||
{
|
||||
var h = (Polygon)hole.Clone();
|
||||
h.Offset(part.X, part.Y);
|
||||
h.UpdateBounds();
|
||||
worldHoles.Add(h);
|
||||
}
|
||||
_placedGate.Add((worldPerimeter, worldHoles));
|
||||
_placedGateFast.Add(part.Orientation.GateFast?.Translated(part.X, part.Y));
|
||||
|
||||
GridAdd(Placed.Count - 1, _inflatedBounds[^1]);
|
||||
}
|
||||
|
||||
// ---- uniform spatial grid (cell -> placed indices) --------------------------
|
||||
|
||||
private void GridAdd(int placedIndex, in Bounds bounds)
|
||||
{
|
||||
var c0 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
|
||||
0,
|
||||
_gridCols - 1
|
||||
);
|
||||
var c1 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
|
||||
0,
|
||||
_gridCols - 1
|
||||
);
|
||||
var r0 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
|
||||
0,
|
||||
_gridRows - 1
|
||||
);
|
||||
var r1 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
|
||||
0,
|
||||
_gridRows - 1
|
||||
);
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
_grid[r * _gridCols + c].Add(placedIndex);
|
||||
}
|
||||
|
||||
private readonly HashSet<int> _nearScratch = new();
|
||||
|
||||
private HashSet<int> Near(in Bounds bounds)
|
||||
{
|
||||
_nearScratch.Clear();
|
||||
var c0 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
|
||||
0,
|
||||
_gridCols - 1
|
||||
);
|
||||
var c1 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
|
||||
0,
|
||||
_gridCols - 1
|
||||
);
|
||||
var r0 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
|
||||
0,
|
||||
_gridRows - 1
|
||||
);
|
||||
var r1 = System.Math.Clamp(
|
||||
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
|
||||
0,
|
||||
_gridRows - 1
|
||||
);
|
||||
for (var r = r0; r <= r1; r++)
|
||||
for (var c = c0; c <= c1; c++)
|
||||
foreach (var index in _grid[r * _gridCols + c])
|
||||
_nearScratch.Add(index);
|
||||
return _nearScratch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legality of one anchor. Outside every overlapping NFP is strict certification;
|
||||
/// inside one still passes when the exact material gate clears (interlocking
|
||||
/// concaves and cutouts that the convex NFP cannot represent).
|
||||
/// </summary>
|
||||
private bool IsLegal(OrientationModel orientation, double x, double y)
|
||||
{
|
||||
if (
|
||||
x + orientation.MinX < _workLeft - 1e-9
|
||||
|| x + orientation.MaxX > _workRight + 1e-9
|
||||
|| y + orientation.MinY < _workBottom - 1e-9
|
||||
|| y + orientation.MaxY > _workTop + 1e-9
|
||||
)
|
||||
return false;
|
||||
|
||||
var pad = Spacing;
|
||||
var candidate = new Bounds(
|
||||
x + orientation.MinX - pad,
|
||||
y + orientation.MinY - pad,
|
||||
x + orientation.MaxX + pad,
|
||||
y + orientation.MaxY + pad
|
||||
);
|
||||
|
||||
// World-space candidate material, built at most once per anchor and only when
|
||||
// a convex-NFP hit actually needs the exact gate; freed with the anchor.
|
||||
(Polygon Perimeter, List<Polygon> Holes)? gate = null;
|
||||
|
||||
foreach (var i in Near(candidate))
|
||||
{
|
||||
var bounds = _inflatedBounds[i];
|
||||
if (
|
||||
candidate.MinX >= bounds.MaxX
|
||||
|| candidate.MaxX <= bounds.MinX
|
||||
|| candidate.MinY >= bounds.MaxY
|
||||
|| candidate.MaxY <= bounds.MinY
|
||||
)
|
||||
continue;
|
||||
|
||||
// Fast rejection: outside the hull-based NFP the placed and candidate
|
||||
// HULLS are at least spacing apart, and hulls contain materials, so the
|
||||
// materials clear - a valid certification for any shape, holed or
|
||||
// concave. Inside the NFP decides nothing by itself (the hull sum
|
||||
// over-approximates for concaves and holes), but when both materials are
|
||||
// convex solids with uncapped hulls the sum is exact (modulo the
|
||||
// circumscribed disk's chord error, which only ever rejects a hair too
|
||||
// much), so interior means overlap. Everything else pays the exact
|
||||
// material gate.
|
||||
DiagCandidateChecks++;
|
||||
var nfp = NfpFor(i, orientation);
|
||||
if (nfp == null)
|
||||
{
|
||||
if (!TryPairVerdict(orientation, x, y, i, out var nullNfpOverlap))
|
||||
{
|
||||
DiagGateCalls++;
|
||||
gate ??= BuildCandidateGate(orientation, x, y);
|
||||
nullNfpOverlap = MaterialOverlap(gate.Value, orientation, x, y, i);
|
||||
}
|
||||
if (nullNfpOverlap)
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
if (!nfp.ContainsPoint(x, y))
|
||||
continue; // outside the conservative forbidden sum: certified clear
|
||||
if (
|
||||
orientation.IsConvexSolid
|
||||
&& Placed[i].Orientation.IsConvexSolid
|
||||
&& orientation.Hull.Count <= MaxHullVertices
|
||||
&& Placed[i].Orientation.Hull.Count <= MaxHullVertices
|
||||
)
|
||||
{
|
||||
DiagConvexRejections++;
|
||||
return false; // exact convex-convex NFP interior: overlap
|
||||
}
|
||||
|
||||
// Cheap world-bbox test against the placed gate material before paying
|
||||
// for candidate gate construction or the clipper.
|
||||
if (
|
||||
!_placedGate[i]
|
||||
.Perimeter.BoundingBox
|
||||
.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y))
|
||||
)
|
||||
continue;
|
||||
|
||||
// Fast shell relation against the placed gate outline: a certified clear
|
||||
// skips the exact gate entirely (no Polygon clones, no triangulation), a
|
||||
// certified overlap rejects without it. Hole-bearing pairs and touches fall
|
||||
// through to the exact gate.
|
||||
if (TryPairVerdict(orientation, x, y, i, out var fastOverlap))
|
||||
{
|
||||
if (fastOverlap)
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
DiagGateCalls++;
|
||||
gate ??= BuildCandidateGate(orientation, x, y);
|
||||
if (MaterialOverlap(gate.Value, orientation, x, y, i))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast outer-shell relation for one (candidate, placed) pair against the placed
|
||||
/// part's spacing-inflated gate outline, deciding whether the exact material gate
|
||||
/// must run. A CERTIFIED verdict skips it: disjoint shells mean no material overlap
|
||||
/// (holes only remove material), and a shell crossing/containment between two
|
||||
/// hole-free polygons IS a positive-area material overlap. Hole-bearing pairs whose
|
||||
/// shells overlap and near-degenerate touches fall through to the exact gate.
|
||||
/// </summary>
|
||||
private bool TryPairVerdict(
|
||||
OrientationModel orientation,
|
||||
double x,
|
||||
double y,
|
||||
int placedIndex,
|
||||
out bool overlap
|
||||
)
|
||||
{
|
||||
overlap = false;
|
||||
var placedFast = _placedGateFast[placedIndex];
|
||||
var candidateFast = orientation.PerimeterFast;
|
||||
if (placedFast == null || candidateFast == null)
|
||||
{
|
||||
DiagFastNull++;
|
||||
return false;
|
||||
}
|
||||
|
||||
var relation = FastPoly.Relate(candidateFast.Translated(x, y), placedFast);
|
||||
if (relation == FastPoly.FastRelation.Overlap)
|
||||
DiagFastOverlapWithHoles++;
|
||||
switch (relation)
|
||||
{
|
||||
case FastPoly.FastRelation.Clear:
|
||||
DiagFastClears++;
|
||||
return true; // certified clear (spacing included in the placed gate)
|
||||
case FastPoly.FastRelation.Overlap
|
||||
when orientation.Holes.Count == 0 && _placedGate[placedIndex].Holes.Count == 0:
|
||||
DiagFastOverlaps++;
|
||||
overlap = true; // certified overlap: shells share area, nothing to subtract
|
||||
return true;
|
||||
default:
|
||||
DiagFastUnknowns++;
|
||||
return false; // exact gate must decide
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool VerifyFastClear { get; set; }
|
||||
|
||||
internal long DiagFastClearMismatch;
|
||||
internal long DiagTriMismatch;
|
||||
internal long DiagTriNull;
|
||||
internal long DiagTriNullOut;
|
||||
internal long DiagTriFallback;
|
||||
|
||||
/// <summary>
|
||||
/// Exact clearance gate against one placed part: placed gate material (inflated by
|
||||
/// spacing when positive) versus the candidate's raw material with holes subtracted.
|
||||
/// </summary>
|
||||
private bool MaterialOverlap(
|
||||
(Polygon Perimeter, List<Polygon> Holes) gate,
|
||||
OrientationModel orientation,
|
||||
double x,
|
||||
double y,
|
||||
int placedIndex
|
||||
)
|
||||
{
|
||||
var placed = _placedGate[placedIndex];
|
||||
if (!placed.Perimeter.BoundingBox.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y)))
|
||||
return false;
|
||||
|
||||
if (!gate.Perimeter.BoundingBox.Intersects(placed.Perimeter.BoundingBox))
|
||||
return false;
|
||||
|
||||
var placedPart = Placed[placedIndex];
|
||||
|
||||
// Allocation-free path: both sides carry cached triangulations in their local
|
||||
// frames, so the test clips triangles with the anchors as plain translations.
|
||||
var candTris = orientation.MaterialTris;
|
||||
var placedTris = placedPart.Orientation.GateTris;
|
||||
if (candTris == null || placedTris == null)
|
||||
DiagTriNull++;
|
||||
if (candTris != null && placedTris != null)
|
||||
{
|
||||
var cached = candTris.HasOverlap(
|
||||
placedTris, x, y, placedPart.X, placedPart.Y
|
||||
);
|
||||
if (cached.HasValue)
|
||||
{
|
||||
if (VerifyFastClear)
|
||||
{
|
||||
var truth = Collision.HasOverlap(
|
||||
gate.Perimeter, placed.Perimeter, gate.Holes, placed.Holes
|
||||
);
|
||||
if (truth != cached.Value)
|
||||
{
|
||||
DiagTriMismatch++;
|
||||
System.IO.File.AppendAllText(
|
||||
"/tmp/triset_mismatch.log",
|
||||
$"cached={cached.Value} truth={truth} candAng={orientation.Angle:F4} at ({x:F8},{y:F8}) " +
|
||||
$"placedAng={placedPart.Orientation.Angle:F4} at ({placedPart.X:F8},{placedPart.Y:F8}) " +
|
||||
$"candTris={candTris} candVerts={orientation.Perimeter.Vertices.Count} holes={orientation.Holes.Count} " +
|
||||
$"placedVerts={placedPart.Orientation.Perimeter.Vertices.Count} placedHoles={placedPart.Orientation.Holes.Count}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
return cached.Value;
|
||||
}
|
||||
DiagTriNullOut++;
|
||||
// Scratch overflow: fall through to the Polygon gate.
|
||||
}
|
||||
DiagTriFallback++;
|
||||
|
||||
return Preparation.MaterialOverlapMemo(
|
||||
placedPart.Orientation,
|
||||
placedPart.X,
|
||||
placedPart.Y,
|
||||
orientation,
|
||||
x,
|
||||
y,
|
||||
() => Collision.HasOverlap(
|
||||
gate.Perimeter,
|
||||
placed.Perimeter,
|
||||
gate.Holes,
|
||||
placed.Holes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private (Polygon, List<Polygon>) BuildCandidateGate(OrientationModel orientation, double x, double y)
|
||||
{
|
||||
var perimeter = (Polygon)orientation.Perimeter.Clone();
|
||||
perimeter.Offset(x, y);
|
||||
perimeter.UpdateBounds();
|
||||
var holes = new List<Polygon>(orientation.Holes.Count);
|
||||
foreach (var hole in orientation.Holes)
|
||||
{
|
||||
var h = (Polygon)hole.Clone();
|
||||
h.Offset(x, y);
|
||||
h.UpdateBounds();
|
||||
holes.Add(h);
|
||||
}
|
||||
return (perimeter, holes);
|
||||
}
|
||||
|
||||
private int OrientationId(OrientationModel orientation)
|
||||
{
|
||||
if (!_orientationIds.TryGetValue(orientation, out var id))
|
||||
{
|
||||
id = _orientationIds.Count;
|
||||
_orientationIds[orientation] = id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convex NFP of forbidden anchors: placedHull (+) disk(spacing) (+) reflect(candidateHull).
|
||||
/// </summary>
|
||||
private ConvexContour? NfpFor(int placedIndex, OrientationModel orientation)
|
||||
{
|
||||
var key = (placedIndex, OrientationId(orientation));
|
||||
if (_nfpCache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
ConvexContour? result;
|
||||
try
|
||||
{
|
||||
if (!_placedHullDisk.TryGetValue(placedIndex, out var placedDisk))
|
||||
{
|
||||
var placed = Placed[placedIndex];
|
||||
var hull = placed.Orientation.Hull;
|
||||
var capped = CapHull(hull, placed.X, placed.Y);
|
||||
var placedHull = ConvexContour.FromVertices(capped);
|
||||
placedDisk = Spacing > Tolerance.Epsilon
|
||||
? NfpGeometry.Minkowski(placedHull, Disk())
|
||||
: placedHull;
|
||||
_placedHullDisk[placedIndex] = placedDisk;
|
||||
}
|
||||
if (!_reflectedHulls.TryGetValue(orientation, out var reflected))
|
||||
{
|
||||
var capped = CapHull(orientation.Hull, 0, 0);
|
||||
reflected = NfpGeometry.Reflect(ConvexContour.FromVertices(capped));
|
||||
_reflectedHulls[orientation] = reflected;
|
||||
}
|
||||
result = NfpGeometry.Minkowski(placedDisk, reflected);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A degenerate Minkowski sum removes the fast rejection for this pair;
|
||||
// the material gate still enforces correctness.
|
||||
result = null;
|
||||
}
|
||||
_nfpCache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded-size convex SUPERSET of <paramref name="hull"/> (translated by dx/dy).
|
||||
/// When the hull is dense, keep every k-th vertex, then shift each chord's
|
||||
/// supporting line outward by the chord's maximum sagitta (the largest distance of
|
||||
/// any dropped vertex to its chord). Every dropped vertex lies within the sagitta
|
||||
/// of its chord, so the offset half-plane intersection contains the original hull
|
||||
/// and the NFP built from it stays a conservative superset of the forbidden anchors.
|
||||
/// </summary>
|
||||
private static List<Vector> CapHull(List<Vector> hull, double dx, double dy)
|
||||
{
|
||||
var n = hull.Count;
|
||||
var shifted = new List<Vector>(n);
|
||||
for (var i = 0; i < n; i++)
|
||||
shifted.Add(new Vector(hull[i].X + dx, hull[i].Y + dy));
|
||||
if (n <= MaxHullVertices)
|
||||
return shifted;
|
||||
|
||||
// Chord (v_i, v_{i+k}) for i in steps of k, with each chord's outward shift:
|
||||
// the max perpendicular distance from any vertex it spans to the chord line.
|
||||
var k = (int)Math.Ceiling(n / (double)MaxHullVertices);
|
||||
var lines = new List<(double ax, double ay, double bx, double by, double shift)>();
|
||||
for (var i = 0; i < n; i += k)
|
||||
{
|
||||
var a = shifted[i];
|
||||
var b = shifted[(i + k) % n];
|
||||
var span = Math.Min(k, n - i);
|
||||
var sagitta = 0.0;
|
||||
var length = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
|
||||
if (length > 1e-12)
|
||||
for (var j = 1; j < span; j++)
|
||||
{
|
||||
var p = shifted[i + j];
|
||||
var distance = Math.Abs(Cross(a.X, a.Y, b.X, b.Y, p)) / length;
|
||||
if (distance > sagitta)
|
||||
sagitta = distance;
|
||||
}
|
||||
lines.Add((a.X, a.Y, b.X, b.Y, sagitta));
|
||||
}
|
||||
|
||||
// Sutherland-Hodgman from a generous bounding box; the interior of each chord
|
||||
// is the CCW left side, shifted outward (left) by the sagitta.
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
foreach (var v in shifted)
|
||||
{
|
||||
if (v.X < minX)
|
||||
minX = v.X;
|
||||
if (v.X > maxX)
|
||||
maxX = v.X;
|
||||
if (v.Y < minY)
|
||||
minY = v.Y;
|
||||
if (v.Y > maxY)
|
||||
maxY = v.Y;
|
||||
}
|
||||
var margin = Math.Max(1.0, Math.Max(maxX - minX, maxY - minY));
|
||||
var polygon = new List<Vector>
|
||||
{
|
||||
new(minX - margin, minY - margin),
|
||||
new(maxX + margin, minY - margin),
|
||||
new(maxX + margin, maxY + margin),
|
||||
new(minX - margin, maxY + margin),
|
||||
};
|
||||
|
||||
foreach (var (ax, ay, bx, by, shift) in lines)
|
||||
{
|
||||
if (polygon.Count == 0)
|
||||
return shifted; // degenerate; fall back to full hull
|
||||
// Shift the line perpendicular away from the interior (CCW: interior is left).
|
||||
var edgeX = bx - ax;
|
||||
var edgeY = by - ay;
|
||||
var length = Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
|
||||
if (length <= 1e-12)
|
||||
continue;
|
||||
var nx = edgeY / length;
|
||||
var ny = -edgeX / length;
|
||||
var ox = ax + nx * shift;
|
||||
var oy = ay + ny * shift;
|
||||
var input = polygon;
|
||||
polygon = new List<Vector>();
|
||||
for (var i = 0; i < input.Count; i++)
|
||||
{
|
||||
var current = input[i];
|
||||
var next = input[(i + 1) % input.Count];
|
||||
var currentInside = Cross(ox, oy, ox + edgeX, oy + edgeY, current) >= 0;
|
||||
var nextInside = Cross(ox, oy, ox + edgeX, oy + edgeY, next) >= 0;
|
||||
if (currentInside)
|
||||
{
|
||||
polygon.Add(current);
|
||||
if (!nextInside)
|
||||
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
|
||||
}
|
||||
else if (nextInside)
|
||||
{
|
||||
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return polygon.Count >= 3 ? polygon : shifted;
|
||||
}
|
||||
|
||||
private static double Cross(double ax, double ay, double bx, double by, Vector p) =>
|
||||
(bx - ax) * (p.Y - ay) - (by - ay) * (p.X - ax);
|
||||
|
||||
private static Vector Intersect(
|
||||
double ax,
|
||||
double ay,
|
||||
double bx,
|
||||
double by,
|
||||
Vector p,
|
||||
Vector q
|
||||
)
|
||||
{
|
||||
var dx1 = bx - ax;
|
||||
var dy1 = by - ay;
|
||||
var dx2 = q.X - p.X;
|
||||
var dy2 = q.Y - p.Y;
|
||||
var cross = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.Abs(cross) < 1e-300)
|
||||
return p;
|
||||
var t = ((p.X - ax) * dy2 - (p.Y - ay) * dx2) / cross;
|
||||
return new Vector(ax + t * dx1, ay + t * dy1);
|
||||
}
|
||||
|
||||
private ConvexContour Disk() =>
|
||||
// Circumscribed so the polygon contains the true spacing disk: the NFP stays a
|
||||
// conservative superset of the forbidden-anchor region.
|
||||
_disk ??= ConvexContour.Disk(Spacing / Math.Cos(Math.PI / 24), 24);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Engine.Qwen38FlashNext.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext;
|
||||
|
||||
/// <summary>
|
||||
/// Independent whole-job nesting engine: bottom-left-first placement over convex
|
||||
/// No-Fit-Polygons with an exact material-clearance gate, driven sheet by sheet by a
|
||||
/// greedy demand scheduler.
|
||||
/// <para>
|
||||
/// Per sheet, parts are demanded in the engine's own order (priority, then the
|
||||
/// largest material area, then id) and each requirement is drained greedily. For a part instance the engine enumerates its
|
||||
/// legal orientations (policy angles, or 0/90/180/270 plus the rotating-calipers
|
||||
/// minimum bounding rectangle for automatic rotation), builds for every placed part a
|
||||
/// convex NFP as placedHull (+) disk(spacing) (+) reflect(candidateHull) via its own
|
||||
/// Minkowski edge-merge, generates the corner-point feasible-region candidates (anchor
|
||||
/// box corners, NFP vertices, NFP-edge/box-line slides), and places the instance at the
|
||||
/// lowest-leftmost candidate whose exact material clearance the engine's collision gate
|
||||
/// accepts (cached-triangulation clip with a sound fast-shell prefilter). Which stock
|
||||
/// the next sheet uses is chosen by re-packing each available size and committing the
|
||||
/// trial that delivers the cheapest plate area per unit of part area placed; the
|
||||
/// job stops when demand is met, stock runs out, nothing further can be placed, or the
|
||||
/// plate cap is hit. See Engine/ for the placement core and README.md for the design
|
||||
/// write-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The engine is self-contained: it calls no built-in <see cref="INestingEngine"/>,
|
||||
/// nester, filler, or runner, and is deterministic - identical input, identical layout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class Qwen38FlashNextNestingEngine : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var preparation = new PartPreparation(job.Parts);
|
||||
var solver = new JobSolver(job, preparation);
|
||||
return solver.Solve(progress, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# OpenNest.Engine.Qwen38FlashNext
|
||||
|
||||
An independent whole-job `INestingEngine` built by Qwen3.8-Flash-Next: **bottom-left greedy
|
||||
insertion over convex no-fit polygons with an exact clearance gate**. It does not call, wrap,
|
||||
or select over any built-in engine, nester, filler, or runner.
|
||||
|
||||
## Algorithm
|
||||
|
||||
Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance
|
||||
gate, driven sheet by sheet by a greedy demand scheduler. Placement and collision preparation remain in `Engine/`; it calls no built-in nester, filler, or runner.
|
||||
|
||||
- **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter +
|
||||
cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon
|
||||
always contains the true material), and caches per-(part, angle, spacing) geometry: bounds,
|
||||
convex hull, and the spacing-inflated outline **rotated into that orientation's frame**
|
||||
(offset commutes with rotation; an unrotated inflation tests the candidate against the
|
||||
material of a different angle - this was a real overlap bug, caught by
|
||||
`RotatedConcavePartsKeepSpacingAtFixedAngles`). Candidate angles are the policy angles, or
|
||||
0/90/180/270 plus the rotating-calipers minimum-bounding-rectangle angle for automatic
|
||||
rotation.
|
||||
- **`SheetPacker`** places one part instance at a time. Per already-placed part it builds a
|
||||
convex NFP as `placedHull (+) disk(spacing) (+) reflect(candidateHull)` via its own
|
||||
Minkowski edge-merge (`Convex.cs`; the merge picks the more-clockwise frontier edge, an
|
||||
inverted comparison here corrupts every non-parallel sum into a self-intersecting contour),
|
||||
then enumerates corner-point candidates: anchor work-box corners, NFP vertices, and
|
||||
NFP-edge/box-line slides, tried in ascending bottom-left order. Because the NFP is
|
||||
hull-based it only *certifies* clearance when both parts are convex solids with uncapped
|
||||
hulls; everything else falls through to the exact gate - placed material inflated by the
|
||||
spacing (holes shrunk, closed holes treated solid) versus the candidate's raw material with
|
||||
holes subtracted, the same inflation rule the benchmark validator uses, so interlocking
|
||||
concave parts are placed legally where the convex NFP alone would reject them. A uniform
|
||||
spatial grid keeps the pair tests near-constant as the sheet fills, and an overlap memo
|
||||
keyed by world pose collapses repeated clipper work across stock trials.
|
||||
- **`FastPoly` / `CachedCollision` (`TriSet`)** make the exact gate cheap. Each orientation
|
||||
caches flat-array triangulations of its raw material and its spacing-inflated gate
|
||||
material; a candidate-vs-placed pair then runs the built-in clipper algorithm on plain
|
||||
double arrays with the anchor offsets as translations - no `Polygon` clones, no
|
||||
per-check re-triangulation, no LINQ. A uniform edge grid on the gate outlines certifies
|
||||
disjoint shell pairs (clear) and convex-solid crossings (overlap) before any clip work;
|
||||
the certification is three-state (touch and collinear contact defer to the clip,
|
||||
containment is decided by sampled interior tests) so it can never report a false clear -
|
||||
cross-validated against `Collision.HasOverlap` over ~2.5M decisions per job run with
|
||||
zero verdict mismatches, and hole-clipping overflow falls back to the exact `Polygon`
|
||||
gate (0.2% of checks on the production job below).
|
||||
- **`JobSolver`** walks demands in its own order (priority, then largest material area -
|
||||
big parts first lay down the sheet skeleton the small parts fill against; measured 12%
|
||||
lower job cost than smallest-extent-first on the production job below) and drains each greedily,
|
||||
then a gap-fill pass capped at eight failed insertion sweeps. For the next sheet it trials *every* available stock
|
||||
size independently and commits the trial delivering the cheapest `NestJobCost.NetSheetArea` per unit of
|
||||
material area placed (the benchmark's cost function), breaking ties by priority coverage,
|
||||
instance count, then plate area; lost trials change no job state. The job stops on met
|
||||
demand, exhausted stock, no further placement, or the plate cap. Deterministic:
|
||||
identical input, identical layout.
|
||||
|
||||
Trade-offs: greedy BLFG insertion leaves some of the density interlocking-pair and
|
||||
compaction pipelines find on regular jobs, and every stock size is trialled per sheet
|
||||
(O(sheets x stocks x fill)); on the 69-drawing/219-part production job below that costs
|
||||
~110 s against the benchmark's 5-minute per-solve timeout. In exchange it places arcs,
|
||||
concaves, and holed parts under one uniform gate with no per-shape-class special cases.
|
||||
|
||||
## Benchmark results
|
||||
|
||||
A real laser-cutting production job: 69 drawings, 219 parts, 3/16 mild steel, spacing 0.3,
|
||||
`--parallel 1`, same OpenNest build for every engine.
|
||||
|
||||
| Sheet sizes offered | Result | Sheets | Utilization | Cost | Time |
|
||||
|---|---|---|---|---|---|
|
||||
| The job's own 4 sizes (60x96, 60x120, 72x120, 48x144) | valid, 219/219 | 28 | 78.4% | 219,744 | ~106 s |
|
||||
| OpenNest's standard 9-size catalog | valid, 219/219 | 14 | 56.6% | 304,128 | ~132 s |
|
||||
|
||||
It uses the fewest sheets of any engine tested, but not the least material. The shop's
|
||||
original hand layout used 29 sheets (191,232 sq in). **Known weakness:** sheet choice is
|
||||
greedy one sheet at a time, so with large stock available it grabs 96x240 sheets and
|
||||
under-fills them.
|
||||
|
||||
Optimization history on this job (all valid, 219/219): count-first trial scoring and
|
||||
span-first demand order cost 258048/39 plates; cost-first trial scoring brought it to
|
||||
249696 (39); area-first demand order to 219744 (28). Wall time went from timeout (>400 s)
|
||||
to ~110 s via the cached-triangulation exact gate and the fast shell prefilter.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/` holds acceptance tests whose layouts are checked by the benchmark's own
|
||||
`NestLayoutCheck` through the shared `Engine.Testing` kit (bounds, spacing, quantities, stock, rotation and accounting), plus NFP geometry tests and a
|
||||
rotated-concave spacing regression test.
|
||||
|
||||
```bash
|
||||
dotnet test OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj
|
||||
```
|
||||
|
||||
## Build and benchmark
|
||||
|
||||
The project is a plugin outside `OpenNest.sln`. `OpenNest.Benchmark` loads plugin engines
|
||||
from an `Engines/` folder next to its own build output:
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.Engine.Qwen38FlashNext/OpenNest.Engine.Qwen38FlashNext.csproj -c Release
|
||||
dotnet build <OpenNest>/OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||
|
||||
mkdir -p <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||
cp OpenNest.Engine.Qwen38FlashNext/bin/Release/net8.0/OpenNest.Engine.Qwen38FlashNext.dll <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||
|
||||
dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder> --parallel 1
|
||||
```
|
||||
|
||||
`<OpenNest>` is the OpenNest checkout root. Or build and deploy in one step with
|
||||
`./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as
|
||||
`Qwen38FlashNextNestingEngine`.
|
||||
|
||||
## Shared services and determinism
|
||||
|
||||
`JobPartGeometry.TryRead` provides normalized material topology. Stock bounds/fit and
|
||||
result accounting/progress use the shared stock API and `NestJobResultBuilder`.
|
||||
`ForShape` supplies Automatic rotations; `EnumerateAngles(maxSamples: 4000)` preserves
|
||||
the sweep effort cap. Solid parts use cached `DistinctOutlines`; holed parts retain every
|
||||
legal candidate because perimeter symmetry alone cannot establish cutout symmetry.
|
||||
CollisionTolerance remains 0.0005, below `NestTolerances.ValidationOutline`.
|
||||
|
||||
Gap fill now counts failed insertion sweeps (default eight), with successful insertions
|
||||
bounded by demand. Internal test settings replace every QWEN environment switch. No
|
||||
stopwatch affects placement or diagnostics. Only the host cancellation token limits wall
|
||||
time. The shared determinism contract compares repeated and fresh solves.
|
||||
|
||||
On the five synthetic salvage jobs, every layout remained valid and complete; total cost
|
||||
fell from 7660.01 to 7572.05, with no job worse. Aggregate measured solve time remained
|
||||
below one second. These small fixtures do not calibrate production-scale retry costs;
|
||||
the eight-sweep default bounds effort independently of hardware. See
|
||||
[PR 5 results](../MIGRATION-PR5.md); older production numbers above describe the old version.
|
||||
@@ -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,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" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,145 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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);
|
||||
|
||||
LayoutAssert.Valid(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);
|
||||
|
||||
LayoutAssert.Valid(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);
|
||||
|
||||
LayoutAssert.Valid(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);
|
||||
|
||||
LayoutAssert.Valid(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);
|
||||
|
||||
LayoutAssert.Valid(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);
|
||||
|
||||
LayoutAssert.Valid(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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class Qwen38FlashNextContractTests : EngineContractTests<Qwen38FlashNextNestingEngine> { }
|
||||
@@ -9,6 +9,7 @@ the OpenNest app or `OpenNest.Benchmark` build output.
|
||||
|--------|----------|
|
||||
| [Gpt6Astra](OpenNest.Engine.Gpt6Astra/) | Contact-based placement |
|
||||
| [Opus55](OpenNest.Engine.Opus55/) | Frontier-advance no-fit-polygon packing |
|
||||
| [Qwen38FlashNext](OpenNest.Engine.Qwen38FlashNext/) | Bottom-left greedy insertion over convex NFPs with an exact clearance gate |
|
||||
|
||||
## Building
|
||||
|
||||
@@ -31,6 +32,12 @@ dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
|
||||
|
||||
Each engine's README covers its algorithm and benchmark results.
|
||||
|
||||
`Engine.Testing/` contains shared xUnit contract tests, shapes, job builders and layout
|
||||
assertions backed by `NestLayoutCheck`. It is a test dependency, not a plugin;
|
||||
`Build-Engines.ps1` only deploys projects in `OpenNest.Engine.*` directories. All engines
|
||||
require the shared-services APIs in the sibling host checkout. See
|
||||
[the PR 5 migration report](MIGRATION-PR5.md) for validation and benchmark results.
|
||||
|
||||
## Writing a new engine
|
||||
|
||||
```powershell
|
||||
@@ -38,11 +45,12 @@ Each engine's README covers its algorithm and benchmark results.
|
||||
```
|
||||
|
||||
This copies `_Template/` to `OpenNest.Engine.Nova/`: an `INestingEngine` stub, a README
|
||||
spelling out what counts as an independent engine, and starter acceptance tests checked by
|
||||
the benchmark's own `NestValidator` (they fail until `Solve()` is implemented).
|
||||
spelling out what counts as an independent engine, `BENCH-RULES.md` (how a model's run
|
||||
works: workspace limits, git, the real-part archive, reporting), and starter acceptance tests checked by
|
||||
the shared `NestLayoutCheck` (they fail until `Solve()` is implemented).
|
||||
|
||||
To work inside an OpenNest checkout instead, stamp it into an `Engines/` folder there and
|
||||
bring the shared build files along; they detect that layout automatically:
|
||||
bring the shared build files and test kit along; they detect that layout automatically:
|
||||
|
||||
```powershell
|
||||
./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Bench rules
|
||||
|
||||
These rules apply to every model building or optimizing an OpenNest engine, so results can be
|
||||
compared fairly. `README.md` in this folder covers what counts as an independent engine; this
|
||||
file covers how the run itself works.
|
||||
|
||||
## Your workspace
|
||||
|
||||
- You are working in a copy of OpenNest. Your engine is `Engines/OpenNest.Engine.__NAME__/`.
|
||||
- Work only inside this copy. Do not search the rest of the filesystem, other checkouts of
|
||||
OpenNest, the network, or any git remote for other nesting engines or earlier versions of
|
||||
this project. Using another engine's code or results in any form is disqualifying.
|
||||
- Do not edit `OpenNest.Core`, `OpenNest.Engine`, `OpenNest.Benchmark` or anything else outside
|
||||
your engine folder, except for throwaway tools under `tools/` and scratch work under
|
||||
`bench-local/` (see below). If shared code has a bug that blocks you, work around it in your
|
||||
engine and describe the bug in your report.
|
||||
|
||||
## Version control
|
||||
|
||||
- At the start, if this copy is not already a git repository, run `git init` in the copy's root
|
||||
and commit everything as `Initial bench copy` before changing anything.
|
||||
- Commit every time the engine reaches a working state (it builds and the tests pass), with a
|
||||
message that says what changed and why, plus any benchmark numbers you measured.
|
||||
- When an experiment makes things worse, go back to the last good commit rather than
|
||||
patching forward. Do not rewrite or squash history; the commit log is part of the record
|
||||
of your run.
|
||||
- Do not add a git remote or push anywhere.
|
||||
|
||||
## Real parts
|
||||
|
||||
- Real production drawings are available read-only at `/mnt/rogers/PEP Drawings/Archive`
|
||||
(on hermes.lan) or `Y:\Archive` (on Windows); both are the same archive.
|
||||
Use them to build realistic benchmark jobs.
|
||||
- Never modify, move or delete anything in the archive.
|
||||
- Do not copy DXF files into your engine folder, and do not commit archive paths, file names or
|
||||
part numbers inside your engine folder. The engine folder may be published; these drawings
|
||||
are customer property. Keep manifests, results and notes that reference the archive under
|
||||
`bench-local/` in the copy's root instead.
|
||||
- Final scoring also uses jobs you will not see. Tune for real parts in general, not for the
|
||||
specific drawings you tested with.
|
||||
|
||||
## Tests and scoring
|
||||
|
||||
- `Engine.Testing/` is the shared, read-only test kit supplied beside engine folders in
|
||||
bench copies. Reference it for shapes, job construction, layout assertions and inherited
|
||||
engine contract tests; do not copy or edit it during an engine optimization run.
|
||||
- Placement must be deterministic: no clocks, unseeded randomness or environment variables
|
||||
may influence placement. Budgets count work. Wall time may stop work only through the
|
||||
host's cancellation token. Diagnostics must not affect placement decisions.
|
||||
- Keep the starter tests in `tests/` and keep them passing. Add tests; do not weaken,
|
||||
skip or delete existing ones. If you believe an existing test is wrong, leave it and explain
|
||||
why in your report.
|
||||
- Engines are scored by `OpenNest.Benchmark`. Every layout goes through `NestValidator`; an
|
||||
invalid layout places nothing and pays the unplaced-part penalty, so validity comes before
|
||||
utilization.
|
||||
- Benchmark with `--parallel 1` whenever you report timing.
|
||||
|
||||
## Your report
|
||||
|
||||
When you finish, update `README.md` in your engine folder to replace the template text with:
|
||||
|
||||
- the algorithm and why you chose it,
|
||||
- what you tried that did not work,
|
||||
- benchmark results (synthetic jobs only in the README; real-part results go in
|
||||
`bench-local/`),
|
||||
- any shared-code bugs or improvements you found, with measured numbers, so they can be
|
||||
upstreamed.
|
||||
+13
-4
@@ -11,10 +11,17 @@ several of them and keep the best result.
|
||||
The decisions that make it an engine must be yours: which sheet(s) to use, which parts go
|
||||
where and in what order, which pattern/strategy to apply to which region, and when to stop.
|
||||
|
||||
**Read `BENCH-RULES.md` before starting.** It covers your workspace, version control, the
|
||||
real-part drawing archive, tests and your final report.
|
||||
|
||||
## Allowed building blocks
|
||||
|
||||
Reuse is encouraged. These are tools you drive, composed by your own decision logic:
|
||||
|
||||
- `OpenNest.Engine.Jobs`: `JobPartGeometry`, stock `WorkArea`/`Area`/`Fits`,
|
||||
`RotationPolicy.EnumerateAngles`, `RotationCandidates`, `NestJobCost`, `NestTolerances`,
|
||||
`NestLayoutCheck`, and `NestJobResultBuilder`. These prepare geometry, check and account
|
||||
for decisions made by your algorithm; they do not choose placements.
|
||||
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
|
||||
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
|
||||
`SpatialQuery`.
|
||||
@@ -43,10 +50,12 @@ measured numbers) so it can be generalized and upstreamed for every engine later
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own
|
||||
`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the
|
||||
benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and
|
||||
add engine-specific tests next to them.
|
||||
`tests/` references the read-only `../Engine.Testing` kit and subclasses
|
||||
`EngineContractTests<TEngine>`. `LayoutAssert.Valid` uses `NestLayoutCheck.Violations`,
|
||||
the benchmark's shared validation primitive, plus strict bounds and accounting checks.
|
||||
The acceptance tests fail until `Solve()` is implemented. Keep them and add engine-specific
|
||||
tests next to them. No clocks, unseeded randomness or environment variables may influence
|
||||
placement; count work for budgets and honor the host cancellation token for wall time.
|
||||
|
||||
```bash
|
||||
dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class __NAME__NestingEngine : INestingEngine
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// TODO: implement independent placement logic here.
|
||||
//
|
||||
@@ -30,9 +31,12 @@ public sealed class __NAME__NestingEngine : INestingEngine
|
||||
// job.Plates -> candidate stock sheets (size, spacing, edge spacing, quadrant, quantity)
|
||||
// job.Options -> job-wide options
|
||||
//
|
||||
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding
|
||||
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id),
|
||||
// and StockUsage (sheets used per stock id).
|
||||
// Read material with JobPartGeometry.TryRead(part.Geometry); use stock.WorkArea/Fits,
|
||||
// RotationCandidates.ForShape and NestJobCost as primitives for your own decisions.
|
||||
// var result = new NestJobResultBuilder(job, progress);
|
||||
// result.AddSheet(stock, poses); // (PartId, X, Y, Rotation); call only on commit.
|
||||
// return result.Build(NestJobStopReason.NoPlacementFound);
|
||||
// The builder assigns sheet/instance indices, fulfillment, usage and commit progress.
|
||||
|
||||
throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented.");
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.__NAME__.csproj" />
|
||||
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
@@ -10,7 +12,7 @@ using OpenNest.Geometry;
|
||||
namespace OpenNest.Engine.__NAME__.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
|
||||
/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck 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>
|
||||
@@ -30,7 +32,7 @@ public class __NAME__NestingEngineTests
|
||||
|
||||
var result = new __NAME__NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
@@ -55,7 +57,7 @@ public class __NAME__NestingEngineTests
|
||||
|
||||
var result = new __NAME__NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ public class __NAME__NestingEngineTests
|
||||
|
||||
var result = new __NAME__NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
}
|
||||
@@ -81,70 +83,11 @@ public class __NAME__NestingEngineTests
|
||||
|
||||
var result = new __NAME__NestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(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;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class __NAME__ContractTests : EngineContractTests<__NAME__NestingEngine> { }
|
||||
|
||||
Reference in New Issue
Block a user