Author SHA1 Message Date
33cc2ee810 docs(engines): template on shared services and a determinism rule
New engines start from the shared test kit (the template's tests are a
one-line EngineContractTests subclass) and the host APIs, so they don't
re-derive geometry reading, work areas or validator tolerances.
BENCH-RULES.md now forbids clocks, unseeded randomness and environment
variables from influencing placement (budgets count work; wall time only
through the host's cancellation token) and lists the kit as read-only.
Build-Engines.ps1 deploys only OpenNest.Engine.* folders, and
New-Engine.ps1 -IncludeBuildFiles copies the kit.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:28 -04:00
0cdef009f8 fix(qwen38flashnext): make layouts deterministic and use host services
The gap-fill pass stopped on a 120 ms stopwatch and QWEN_* environment
variables switched strategies, so the same job could nest differently
with machine load or environment. Gap fill now stops after eight failed
insertion sweeps, and the switches are internal properties with the
old defaults. Part reading, work area, rotation angles, scoring and
result assembly now use the host APIs; its collision gate is unchanged.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost 7660.01 ->
7572.05, time 839 -> 553 ms. Not yet calibrated on production-size jobs.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:28 -04:00
0308a862b8 fix(opus55): honor part priority and use host scoring and tolerances
Opus55 ignored NestJobPart.Priority, so the shared contract test (lower
number wins scarce stock) failed; lower-number priority now precedes its
placement score. SheetEconomics is replaced by the host's NestJobCost so
it optimizes exactly what the benchmark scores, and its footprint margin
comes from NestTolerances.SafeClearanceMargin plus four Clipper grid
units - the same 0.003 total as before, which keeps its contact points.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost unchanged at
5452.79, time 611 -> 456 ms.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:27 -04:00
d0c6af783b refactor(gpt6astra): use host geometry, tolerances and layout checks
Gpt6Astra reverse-engineered the validator: hand-tuned paddings and a
copied check sequence (ValidationOverlap) to match its rounding. It now
reads parts with JobPartGeometry, takes clearance from NestTolerances,
checks candidates with NestLayoutCheck.Clears, and assembles results with
NestJobResultBuilder and NestJobCost; its tests use the shared kit. Its
contact search, beam search and extra Automatic angles are unchanged.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, 2 sheets each,
cost 5574.07 -> 5470.07; time 1871 -> 2400 ms from the stricter shared
check on arc-heavy jobs.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:27 -04:00
c691381f30 test(engines): add a shared engine test kit and contract tests
Each engine's tests carried its own copy of the job/shape helpers and
validation, and they had drifted: Gpt6Astra linked NestValidator.cs as
source (which stopped compiling once the host moved its checks into
NestLayoutCheck), the others referenced OpenNest.Benchmark. The kit
provides JobBuilder, Shapes, LayoutAssert (backed by the host's public
NestLayoutCheck) and EngineContractTests<TEngine>: quadrants, overflow,
oversize parts, lower-number priority, etch marks, sequential plate
indices, determinism, cancellation and stop reasons, for every engine.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:29:26 -04:00
ajandClaude Opus 5.5 54e8a0461b fix(qwen38flashnext): number plates in commit order and drop etch marks
BuildResult set PlateIndex to the stock index, so every sheet cut from
the same stock shared one index; OpenNest.Api maps plates by it.

Part geometry filtered only rapids, so scribe/etch moves counted as
material - the bug OpenNest fixed in 1b5e1b1. Use SpecialLayers.IsMaterial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:45:13 -04:00
ajandClaude Opus 5.5 1579fa6810 fix(opus55): cut clearance margin to 0.003 and drop etch marks from geometry
The 0.022 margin assumed validators flatten arcs at 0.01; NestValidator
uses 0.001, so a pair can read at most 0.002 closer than true. 0.003
covers that plus the 1e-4 Clipper grid, returning ~0.019 per gap. Arc,
disc, obround and zero-spacing tests still pass.

Part geometry filtered only rapids, so scribe/etch moves counted as
material - the bug OpenNest fixed in 1b5e1b1. Use SpecialLayers.IsMaterial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:45:12 -04:00
ajandClaude Opus 5.5 ffa3590b46 fix(gpt6astra): rank lower Priority first and drop etch marks from geometry
Priority was sorted descending, the reverse of the host (StockLadder and
NestJobCandidateComparer treat a lower number as more important), so a
priority-9 part beat a priority-0 part for scarce stock. The existing test
encoded the inverted rule and now asserts the host's direction.

Part geometry filtered only rapids, so scribe/etch moves counted as
material - the bug OpenNest fixed in 1b5e1b1. Use SpecialLayers.IsMaterial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:45:12 -04:00
ajandClaude Opus 5.5 f973c1c15c docs(template): list the Windows path of the drawing archive
Runs with Claude have to happen on the Windows PC, where the archive is
mapped as Y:\Archive rather than hermes' /mnt/rogers mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:03:28 -04:00
ajandClaude Opus 5.5 90f07603e2 feat(qwen38flashnext): add the finished engine
Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run
(its commit 7d7fca3): cost-first sheet trials, largest-area-first
demand order, and a cached-triangulation exact gate that brought a
219-part production job from timeout to ~106 s. 13/13 tests pass
against OpenNest master.

README cleaned for publishing: the model-facing template rules are
replaced by a one-line independence statement, the production job is
described generically instead of by its PEP job/file name (also in a
JobSolver comment), results show both sheet pools as re-measured here
(the 9-size claim in its report didn't reproduce: it grabs 96x240 and
under-fills them), and the stale StockLadder-crash note is gone now
that core leaves etch marks out of nesting.

Also drops a stale Aurora plugin reference from Opus55's README and
lists the engine in the repo README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:57:11 -04:00
ajandClaude Opus 5.5 ffd0187fbb docs(template): add BENCH-RULES.md for engine-building runs
Rules for the runs were being made up mid-run (git for rollback, access
to the real-part drawing archive), so each model started under a
different rule set. BENCH-RULES.md writes them down in one place, stamped
into every new engine, so all models work under the same rules:
workspace limits and no searching for other engines, git init plus
commit-per-working-state, the archive as read-only with nothing
customer-identifying kept in the (publishable) engine folder, tests may
only be added to, and what the final report must cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:32:42 -04:00
43 changed files with 4231 additions and 641 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ if (-not (Test-Path (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchm
} }
$OpenNestRoot = (Resolve-Path $OpenNestRoot).Path $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.' } if ($LASTEXITCODE -ne 0) { throw 'OpenNest.Benchmark build failed.' }
$deployDir = Join-Path $OpenNestRoot "OpenNest.Benchmark/bin/$Configuration/net8.0/Engines" $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) { foreach ($dir in $projects) {
$csproj = Join-Path $dir.FullName "$($dir.Name).csproj" $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." } if ($LASTEXITCODE -ne 0) { throw "$($dir.Name) build failed." }
$dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll" $dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll"
+135
View File
@@ -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); }
}
+26
View File
@@ -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));
}
+50
View File
@@ -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>
+56
View File
@@ -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
View File
@@ -7,8 +7,8 @@
placeholder in file names and contents. The new engine builds against OpenNest via 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. Directory.Build.props, which must sit in <Destination> or a parent folder.
With -IncludeBuildFiles, Directory.Build.props/.targets are copied into <Destination> With -IncludeBuildFiles, Directory.Build.props/.targets and the shared Engine.Testing
too, so the engine can live outside this repo, e.g. in an Engines/ folder inside an 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). OpenNest checkout (the props detect that layout on their own).
.EXAMPLE .EXAMPLE
@@ -29,21 +29,28 @@ $target = Join-Path $Destination "OpenNest.Engine.$Name"
if (Test-Path $target) { throw "'$target' already exists." } if (Test-Path $target) { throw "'$target' already exists." }
New-Item -ItemType Directory -Force $Destination | Out-Null New-Item -ItemType Directory -Force $Destination | Out-Null
Copy-Item $template $target -Recurse # A built template contains binary/obj files. Copy only source files, never rewrite binaries.
$template = (Resolve-Path $template).Path
Get-ChildItem $target -Recurse -File | ForEach-Object { Get-ChildItem $template -Recurse -File |
$text = [IO.File]::ReadAllText($_.FullName) Where-Object { $_.FullName.Substring($template.Length) -notmatch '[\\/](bin|obj)[\\/]' } |
[IO.File]::WriteAllText($_.FullName, $text.Replace('__NAME__', $Name)) ForEach-Object {
} $relative = $_.FullName.Substring($template.Length + 1).Replace('__NAME__', $Name)
# Deepest paths first so renaming a folder never invalidates a pending child path. $output = Join-Path $target $relative
Get-ChildItem $target -Recurse | Where-Object Name -like '*__NAME__*' | New-Item -ItemType Directory -Force (Split-Path $output -Parent) | Out-Null
Sort-Object { $_.FullName.Length } -Descending | $text = [IO.File]::ReadAllText($_.FullName)
ForEach-Object { Rename-Item $_.FullName $_.Name.Replace('__NAME__', $Name) } [IO.File]::WriteAllText($output, $text.Replace('__NAME__', $Name))
}
if ($IncludeBuildFiles) { if ($IncludeBuildFiles) {
foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') { foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') {
Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force 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" Write-Host "Created $target"
+4 -1
View File
@@ -1,5 +1,6 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using M = System.Math; using M = System.Math;
namespace OpenNest.Engine.Gpt6Astra; namespace OpenNest.Engine.Gpt6Astra;
@@ -47,7 +48,9 @@ internal sealed class ContactGeometry
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var delta = spacing + stationary.ContactError + moving.ContactError 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, if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round,
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001); EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
// Bound cache residency for jobs with many distinct rotation pairs. // Bound cache residency for jobs with many distinct rotation pairs.
+27 -49
View File
@@ -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> /// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token) 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 readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new();
private double validationOriginX; private double validationOriginX;
private double validationOriginY; private double validationOriginY;
internal SheetTrial Pack(int stockIndex, NestPlateStock stock, int[] committed, int[] flexibility, int mode) 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; validationOriginX = stock.WorkArea.Left;
validationOriginY = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom; validationOriginY = stock.WorkArea.Bottom;
var width = stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right; var width = stock.WorkArea.Length;
var height = stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top; var height = stock.WorkArea.Width;
var counts = (int[])committed.Clone(); var counts = (int[])committed.Clone();
var placed = new List<PackedShape>(); var placed = new List<PackedShape>();
var spaces = new Dictionary<int, SearchSpace>(); var spaces = new Dictionary<int, SearchSpace>();
var order = Enumerable.Range(0, parts.Length) var order = Enumerable.Range(0, parts.Length)
.OrderByDescending(i => parts[i].Requirement.Priority) .OrderBy(i => parts[i].Requirement.Priority)
.ThenBy(i => flexibility[i]) .ThenBy(i => flexibility[i])
.ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min()) .ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min())
.ThenBy(i => i).ToArray(); .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. // separately, then validate against material, not the outer envelope.
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p))) 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 l = hole.Min(p => p.x) + other.X + spacing + (4 * GridUnit);
var b = hole.Min(p => p.y) + other.Y + spacing + 0.0004; var b = hole.Min(p => p.y) + other.Y + spacing + (4 * GridUnit);
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - 0.0004; 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 - 0.0004; var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - (4 * GridUnit);
if (r < l || t < b) continue; 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); 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. // 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 // Exact contacts can be invalid only after the host's four-decimal
// polygon rounding. Try nearby outward contacts without changing angle. // polygon rounding. Try nearby outward contacts without changing angle.
foreach (var (dx, dy) in new (double, double)[] { foreach (var (dx, dy) in new (double, double)[] {
(0.0003, 0), (0, 0.0003), (0.0003, 0.0003), (-0.0003, 0), ((3 * GridUnit), 0), (0, (3 * GridUnit)), ((3 * GridUnit), (3 * GridUnit)), (-(3 * GridUnit), 0),
(0, -0.0003), (-0.0003, 0.0003), (0.0003, -0.0003), (-0.0003, -0.0003) }) (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 }; var nudged = pose with { X = pose.X + dx, Y = pose.Y + dy };
if (nudged.X < 0 || nudged.Y < 0 || nudged.X > maxX || nudged.Y > maxY) continue; if (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) private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
{ {
PathsD? material = null; PathsD? material = null;
PathsD? validationMaterial = null;
foreach (var other in placed) 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 || if (candidate.X >= other.X + other.Variant.Width + gap ||
other.X >= candidate.X + candidate.Variant.Width + gap || other.X >= candidate.X + candidate.Variant.Width + gap ||
candidate.Y >= other.Y + other.Variant.Height + 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 obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits); var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false; if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false;
validationMaterial ??= GeometryPrecision.Translate(candidate.Variant.ValidationRegion(0), candidate.X, candidate.Y); var key = (candidate.Variant.Id, other.Variant.Id, spacing,
var validationObstacle = GeometryPrecision.Translate(other.Variant.ValidationRegion(spacing), other.X, other.Y); candidate.X + validationOriginX, candidate.Y + validationOriginY,
if (M.Abs(Clipper.Area(Clipper.Intersect(validationMaterial, validationObstacle, other.X + validationOriginX, other.Y + validationOriginY);
FillRule.NonZero, GeometryPrecision.Digits))) > 1e-8) return false; if (!validationCache.TryGetValue(key, out var collides))
if (spacing == 0 || candidate.Variant.Material.Count > 1 || other.Variant.Material.Count > 1)
{ {
var outerIntersection = Clipper.Intersect( NestJobPlacement Pose(PackedShape p) => new("check", 0,
new PathsD(validationMaterial.Where(Clipper.IsPositive)), validationOriginX + p.X - p.Variant.OriginX,
new PathsD(validationObstacle.Where(Clipper.IsPositive)), FillRule.NonZero, GeometryPrecision.Digits); validationOriginY + p.Y - p.Variant.OriginY, p.Variant.Angle);
if (spacing != 0 && M.Abs(Clipper.Area(outerIntersection)) <= 1e-8) continue; // Equal-left ties follow commit order, just as the full-layout check does.
var key = (candidate.Variant.Id, other.Variant.Id, spacing, collides = !NestLayoutCheck.Clears(other.Variant.Geometry, Pose(other),
candidate.X + validationOriginX, candidate.Y + validationOriginY, candidate.Variant.Geometry, Pose(candidate), spacing);
other.X + validationOriginX, other.Y + validationOriginY); if (validationCache.Count >= 4096) validationCache.Clear();
if (!validationCache.TryGetValue(key, out var collides)) validationCache[key] = 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;
} }
if (collides) return false;
} }
return true; 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); NestJobValidator.Validate(job);
var parts = GeometryPreparation.Prepare(job, token); var parts = GeometryPreparation.Prepare(job, token);
var fit = parts.Select(p => job.Plates.Select(s => p.Variants.Any(v => 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 && s.Fits(v.Width, v.Height))).ToArray()).ToArray();
v.Height <= s.Size.Width - s.EdgeSpacing.Top - s.EdgeSpacing.Bottom + 1e-9)).ToArray()).ToArray();
var placer = new ContactPlacer(parts, new ContactGeometry(), token); 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 initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
var frontier = new List<Plan> { initial }; var frontier = new List<Plan> { initial };
var best = initial; var best = initial;
Plan? complete = IsComplete(initial) ? initial : null; Plan? complete = IsComplete(initial) ? initial : null;
var trials = new Dictionary<string, SheetTrial>(StringComparer.Ordinal); 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 evaluated = 0;
var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray(); var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray();
while (frontier.Count > 0) while (frontier.Count > 0)
@@ -56,7 +55,9 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
evaluated++; evaluated++;
} }
if (trial.Shapes.Count == 0) continue; 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++) for (var p = 0; p < parts.Length; p++)
{ {
var delivered = trial.Counts[p] - state.Counts[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 used = (int[])state.Used.Clone(); used[s]++;
var sheets = new List<SheetTrial>(state.Sheets) { trial }; var sheets = new List<SheetTrial>(state.Sheets) { trial };
var next = new Plan(trial.Counts, used, sheets, 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 (BetterFulfillment(next, best)) best = next;
if (IsComplete(next)) if (IsComplete(next))
{ {
@@ -97,29 +98,26 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
} }
} }
var selected = complete ?? best; var selected = complete ?? best;
var counts = new int[parts.Length]; var builder = new NestJobResultBuilder(job, progress);
var plates = new List<NestJobPlateResult>();
foreach (var sheet in selected.Sheets) foreach (var sheet in selected.Sheets)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var stock = job.Plates[sheet.StockIndex]; builder.AddSheet(job.Plates[sheet.StockIndex], Poses(sheet));
var x = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
var y = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
var placements = sheet.Shapes.Select(p => new NestJobPlacement(job.Parts[p.Variant.Part].Id,
counts[p.Variant.Part]++, x + p.X - p.Variant.OriginX,
y + p.Y - p.Variant.OriginY, p.Variant.Angle)).ToArray();
plates.Add(new(plates.Count, stock, placements));
progress?.Report(new(NestJobStage.PlateCommitted, stock.Id, plates.Count - 1,
plates.Count, counts.Sum()));
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var reason = complete != null ? NestJobStopReason.Completed : var reason = complete != null ? NestJobStopReason.Completed :
selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached : selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached :
!Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted : !Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted :
NestJobStopReason.NoPlacementFound; NestJobStopReason.NoPlacementFound;
return new(complete != null ? NestJobStatus.Complete : NestJobStatus.Incomplete, reason, plates, return builder.Build(reason);
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]))); 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 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); bool IsComplete(Plan p) => parts.Select((part, i) => p.Counts[i] == part.Requirement.Quantity).All(v => v);
+19 -46
View File
@@ -1,7 +1,5 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
using M = System.Math; using M = System.Math;
@@ -25,24 +23,11 @@ internal sealed class ShapeVariant
internal required double ContactError { get; init; } internal required double ContactError { get; init; }
internal required Polygon Hull { get; init; } internal required Polygon Hull { get; init; }
internal required bool Convex { 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) && internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
GridAligned(Width) && GridAligned(Height) && GridAligned(Width) && GridAligned(Height) &&
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * 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 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(); private readonly Dictionary<double, PathsD> halos = new();
internal PathsD Halo(double spacing) internal PathsD Halo(double spacing)
@@ -50,7 +35,7 @@ internal sealed class ShapeVariant
if (halos.TryGetValue(spacing, out var cached)) return cached; if (halos.TryGetValue(spacing, out var cached)) return cached;
// Raw outlines already circumscribe curves; the extra clearance covers independent // Raw outlines already circumscribe curves; the extra clearance covers independent
// flattenings after pose materialization and the validator's four-decimal grid. // 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, return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001); JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
} }
@@ -77,17 +62,19 @@ internal static class GeometryPreparation
return job.Parts.Select((part, index) => return job.Parts.Select((part, index) =>
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry)) var geometry = JobPartGeometry.Read(part.Geometry);
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToList(); var baseProfile = geometry.Profile;
// Input validation has established that open marks lie inside material. They var closed = new[] { geometry.Perimeter }.Concat(geometry.Cutouts)
// must not be interpreted as holes by ShapeProfile. .SelectMany(shape => shape.Entities).ToList();
var closed = ShapeBuilder.GetShapes(entities).Where(s => s.IsClosed()) var area = geometry.MaterialArea;
.SelectMany(s => s.Entities).ToList();
var baseProfile = new ShapeProfile(closed);
var area = baseProfile.Perimeter.Area() - baseProfile.Cutouts.Sum(h => h.Area());
var variants = new List<ShapeVariant>(); var variants = new List<ShapeVariant>();
var keys = new HashSet<string>(StringComparer.Ordinal); 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(); token.ThrowIfCancellationRequested();
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList(); 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; var h = rotated.Max(e => e.Top) - y;
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0) if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
throw new ArgumentException($"Unusable rotated bounds: {part.Id}."); 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); foreach (var e in rotated) e.Offset(-x, -y);
var profile = new ShapeProfile(rotated); 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 // Circular/symmetric parts should not multiply identical NFP work. Compare
// normalized closed contours, including holes, independent of start vertex. // normalized closed contours, including holes, independent of start vertex.
var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal)); var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal));
if (!keys.Add(key)) continue; 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 hull = ConvexHull.Compute(outline.Vertices);
var convex = M.Abs(hull.Area() - outline.Area()) < 1e-7 * M.Max(1, hull.Area()); 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 // 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, OriginX = x, OriginY = y, Width = w, Height = h,
Curved = rotated.Any(e => e is Arc or Circle), Material = material, Curved = rotated.Any(e => e is Arc or Circle), Material = material,
Outline = outline, ContactOutline = contactOutline, ContactError = contactError, 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(); var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8) 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. // discard every fitting orientation merely because its envelope is larger.
foreach (var stock in job.Plates) foreach (var stock in job.Plates)
{ {
bool Fits(ShapeVariant v) => v.Width <= stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right + 1e-9 && bool Fits(ShapeVariant v) => stock.Fits(v.Width, v.Height);
v.Height <= stock.Size.Width - stock.EdgeSpacing.Top - stock.EdgeSpacing.Bottom + 1e-9;
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4)); if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
} }
ordered = shortlist.DistinctBy(v => v.Id).ToArray(); ordered = shortlist.DistinctBy(v => v.Id).ToArray();
@@ -151,7 +136,7 @@ internal static class GeometryPreparation
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile) 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) if (policy.Kind == RotationPolicyKind.Automatic)
{ {
// All half-turns matter for asymmetric parts, unlike envelope-only packing. // All half-turns matter for asymmetric parts, unlike envelope-only packing.
@@ -163,19 +148,7 @@ internal static class GeometryPreparation
} }
} }
else else
{ values = policy.EnumerateAngles().ToList();
var last = policy.Kind == RotationPolicyKind.Fixed ? 0 : M.Floor((policy.End - policy.Start) / policy.Step);
if (!double.IsFinite(last)) last = 720;
var samples = (int)M.Min(720, last);
for (var i = 0; i <= samples; i++)
{
var k = samples == 0 ? 0 : M.Floor(last * ((double)i / samples));
var angle = policy.Start + k * policy.Step;
if (!double.IsFinite(angle) || !policy.Allows(angle)) continue;
values.Add(angle);
if (policy.Allow180Equivalent) values.Add(angle + M.PI);
}
}
var seen = new HashSet<long>(); var seen = new HashSet<long>();
foreach (var value in values) foreach (var value in values)
{ {
+23 -11
View File
@@ -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, This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles,
concave interlocking, and insertion into straight-edged and curved holes. concave interlocking, and insertion into straight-edged and curved holes.
1. Validate immutable job input. Reconstruct owned analytic entities with `DrawingJobMapper` 1. Validate immutable job input. Read owned analytic material with `JobPartGeometry.Read`. Closed contours define material; internal open marks do not become
and `ConvertProgram`. Closed contours define material; internal open marks do not become
holes. Preserve the snapshot's origin when converting normalized placements back to poses. 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. 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 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 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 a candidate stock. Fixed and bounded rotation policies remain enforced. Bounded sweeps use
up to 721 integer step indices, including permitted half-turn equivalents. up to 720 base samples through `RotationPolicy.EnumerateAngles`, including permitted half-turn equivalents.
3. Process high-priority parts first, then parts fitting fewer available stock types, then larger 3. Process high-priority parts first (a lower `Priority` number ranks higher, as in the host),
envelopes. Larger frames precede inserts. Search every retained orientation for each instance. 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; 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 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 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 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. trials only one directional objective is used; after 64, beam width reduces to two.
Work counts, not elapsed time or randomness, control search breadth. 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. 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 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. 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 ## Precision and safety
Analytic rotated bounds govern sheet containment. Material curves are conservatively flattened 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 at `NestTolerances.ValidationOutline`. Contact generation and material halos both use
straight outlines; curved outlines reserve 0.003 extra units even at zero spacing, accounting for offset/chord error and the `SafeClearanceMargin` so proposed contacts pass the same clearance gate. Axis-aligned
benchmark validator's four-decimal grid. Axis-aligned rectangle contacts preserve exact requested rectangle contacts preserve exact requested spacing. `NestLayoutCheck.Clears` replaces
spacing. Actual material intersection checks backstop candidate construction. Both straight-edged 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 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 triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
curved-hole false positive. See the benchmark report for regression results. 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. 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. 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. 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> <PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" /> <ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -7,7 +7,6 @@ using OpenNest.CNC;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Benchmark;
using CncProgram = OpenNest.CNC.Program; using CncProgram = OpenNest.CNC.Program;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
@@ -30,10 +29,8 @@ if (args.Contains("--diagnose-ring"))
new NestJobPlacement("insert", 0, x + offset, y, 0) }) }, new NestJobPlacement("insert", 0, x + offset, y, 0) }) },
new[] { new PartFulfillment("ring", 1, 1, 0), new PartFulfillment("insert", 1, 1, 0) }, new[] { new PartFulfillment("ring", 1, 1, 0), new PartFulfillment("insert", 1, 1, 0) },
new[] { new StockUsage("s", 1, 0) }); new[] { new StockUsage("s", 1, 0) });
var materialized = NestResultMaterializer.Materialize(j, result); var violations = NestLayoutCheck.Violations(j, result);
var check = NestValidator.Validate(materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), Console.WriteLine($"q={quadrant} offset={offset} valid={violations.Count == 0}: {string.Join(';', violations)}");
j.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
Console.WriteLine($"q={quadrant} offset={offset} valid={check.Valid}: {string.Join(';', check.Violations)}");
} }
return; 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[] { 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)); 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()); 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) foreach (var (name, job) in cases)
{ {
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue; if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
@@ -82,13 +79,10 @@ foreach (var (name, job) in cases)
try try
{ {
var result = engine.Solve(job, token: cts.Token); sw.Stop(); var result = engine.Solve(job, token: cts.Token); sw.Stop();
var nest = NestResultMaterializer.Materialize(job, result); var violations = NestLayoutCheck.Violations(job, result);
var validation = NestValidator.Validate(nest.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), if (violations.Count != 0) Environment.ExitCode = 1;
job.Parts.ToDictionary(p => nest.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity))); 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}");
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation); foreach (var violation in violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
if (!validation.Valid) Environment.ExitCode = 1;
Console.WriteLine($"{name},{validation.Valid},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width)},{sw.ElapsedMilliseconds}");
foreach (var violation in validation.Violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
} }
catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); } 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 ## Synthetic cases
Both versions were run on exactly the same programmatically generated geometry and stock. 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, quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only,
exclude external validation, and are single-run observations rather than stable distributions. 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`. 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.CNC;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
@@ -23,7 +26,7 @@ public class Gpt6AstraNestingEngineTests
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray(); var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); 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); for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions);
var again = new Gpt6AstraNestingEngine().Solve(job); var again = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements)); 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)) }); new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal("small", Assert.Single(result.Plates).StockId); Assert.Equal("small", Assert.Single(result.Plates).StockId);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Theory] [Theory]
@@ -50,7 +53,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(reason, result.StopReason); Assert.Equal(reason, result.StopReason);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -83,7 +86,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) }); }, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -102,8 +105,11 @@ public class Gpt6AstraNestingEngineTests
[Fact] [Fact]
public void PriorityWinsScarceSpaceAndProgressReflectsCommits() public void PriorityWinsScarceSpaceAndProgressReflectsCommits()
{ {
var low = Rectangle("low", 2, 2, 1); // Lower Priority number ranks higher, as in StockLadderNestingEngine and
var high = new NestJobPart("high", low.Geometry, 1, priority: 9); // 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 job = new NestJob(new[] { low, high }, new[] { new NestPlateStock("s", new Size(2, 2), 1) });
var updates = new List<NestJobProgress>(); var updates = new List<NestJobProgress>();
var result = new Gpt6AstraNestingEngine().Solve(job, new CallbackProgress(updates.Add)); 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) }); }, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -145,7 +151,7 @@ public class Gpt6AstraNestingEngineTests
}); });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); 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); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -173,7 +179,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -185,7 +191,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId); Assert.Equal("large", Assert.Single(result.Plates).StockId);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -195,7 +201,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(8, 8), 1) }); new[] { new NestPlateStock("s", new Size(8, 8), 1) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -207,7 +213,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) }); new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -219,7 +225,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600); Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -229,7 +235,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) }); new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Theory] [Theory]
@@ -247,7 +253,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) }); }, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); 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); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -284,64 +290,32 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token); var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); 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> private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress>
{ public void Report(NestJobProgress value) => callback(value); } { 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>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <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" /> <ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+7 -18
View File
@@ -63,25 +63,9 @@ internal sealed class FrontierPacker
this.stock = stock; this.stock = stock;
this.axis = axis; this.axis = axis;
this.beta = beta; 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) public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
{ {
var left = remaining.ToArray(); var left = remaining.ToArray();
@@ -91,7 +75,7 @@ internal sealed class FrontierPacker
if (left[type.Index] <= 0) if (left[type.Index] <= 0)
continue; continue;
foreach (var o in type.Orientations) foreach (var o in type.Orientations)
if (Fits(o, work)) if (stock.Fits(o.Width, o.Height))
states.Add(new Region(o, work)); states.Add(new Region(o, work));
} }
@@ -140,17 +124,21 @@ internal sealed class FrontierPacker
var bestValue = double.PositiveInfinity; var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity; var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity; var bestLead = double.PositiveInfinity;
var bestPriority = int.MaxValue;
foreach (var region in states) foreach (var region in states)
{ {
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead)) if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue; continue;
var area = types[region.Orientation.TypeIndex].Area; var area = types[region.Orientation.TypeIndex].Area;
var priority = types[region.Orientation.TypeIndex].Part.Priority;
if (priority > bestPriority) continue;
var fills = advance <= Tie; var fills = advance <= Tie;
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area. // 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 value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
var better = bestRegion == null var better = bestRegion == null
|| priority < bestPriority
|| (fills && !bestFills) || (fills && !bestFills)
|| ( || (
fills == bestFills fills == bestFills
@@ -165,6 +153,7 @@ internal sealed class FrontierPacker
if (!better) if (!better)
continue; continue;
bestRegion = region; bestRegion = region;
bestPriority = priority;
bestPoint = point; bestPoint = point;
bestFills = fills; bestFills = fills;
bestValue = value; bestValue = value;
+6 -2
View File
@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Opus55; namespace OpenNest.Engine.Opus55;
@@ -15,7 +16,7 @@ namespace OpenNest.Engine.Opus55;
internal sealed class NoFitCache internal sealed class NoFitCache
{ {
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary> /// <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 double halfClearance;
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new(); 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". // footprint is a superset of "every point within the clearance of the outline".
var inflated = Clipper.InflatePaths( var inflated = Clipper.InflatePaths(
new PathsD { o.Outline }, 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, JoinType.Miter,
EndType.Polygon, EndType.Polygon,
2.0, 2.0,
+20 -39
View File
@@ -19,13 +19,6 @@ namespace OpenNest.Engine.Opus55;
/// </summary> /// </summary>
public sealed class Opus55NestingEngine : INestingEngine 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> /// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
private static readonly (PackAxis Axis, double Beta)[] Variants = private static readonly (PackAxis Axis, double Beta)[] Variants =
{ {
@@ -50,6 +43,7 @@ public sealed class Opus55NestingEngine : INestingEngine
) )
{ {
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job); var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token); var solver = new Solver(job, types, progress, token);
@@ -59,7 +53,7 @@ public sealed class Opus55NestingEngine : INestingEngine
{ {
var placeable = job.Plates.Any(stock => var placeable = job.Plates.Any(stock =>
stock.Quantity != 0 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; demand[type.Index] = placeable ? type.Part.Quantity : 0;
} }
@@ -95,7 +89,7 @@ public sealed class Opus55NestingEngine : INestingEngine
public WorkCounter Work { get; } = new(); 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) 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 prefix = sheets.Take(sheets.Count - k).ToList();
var tail = sheets.Skip(sheets.Count - k).ToList(); var tail = sheets.Skip(sheets.Count - k).ToList();
var tailParts = tail.Sum(s => s.Parts.Count); 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]; var tailDemand = new int[types.Count];
foreach (var part in tail.SelectMany(s => s.Parts)) foreach (var part in tail.SelectMany(s => s.Parts))
tailDemand[part.Orientation.TypeIndex]++; tailDemand[part.Orientation.TypeIndex]++;
@@ -154,7 +148,7 @@ public sealed class Opus55NestingEngine : INestingEngine
private NoFitCache CacheFor(NestPlateStock stock) 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)) if (!caches.TryGetValue(clearance, out var cache))
caches[clearance] = cache = new NoFitCache(clearance); caches[clearance] = cache = new NoFitCache(clearance);
return cache; return cache;
@@ -205,7 +199,7 @@ public sealed class Opus55NestingEngine : INestingEngine
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work); var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
var fill = packer.Fill(remaining, token); var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0) if (fill.Parts.Count > 0)
trials.Add((fill, SheetEconomics.NetArea(job.Options, fill))); trials.Add((fill, NetArea(job.Options, fill)));
} }
if (trials.Count == 0) 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 sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
private static NestJobResult BuildResult( private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
NestJob job, Plan plan, IProgress<NestJobProgress>? progress)
IReadOnlyList<PartType> types,
Plan plan,
IProgress<NestJobProgress>? progress
)
{ {
var placed = new int[types.Count]; var builder = new NestJobResultBuilder(job, progress);
var plates = new List<NestJobPlateResult>(plan.Sheets.Count);
var committedParts = 0;
foreach (var sheet in plan.Sheets) foreach (var sheet in plan.Sheets)
{ builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
var placements = sheet.Parts.Select(p => (types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
{ return builder.Build(plan.Reason);
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));
}
var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index])); private static double NetArea(NestJobOptions options, SheetFill fill)
var usage = job.Plates.Select(stock => {
{ if (fill.Parts.Count == 0) return fill.Stock.Area;
var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock)); var left = fill.Parts.Min(p => p.Left);
return new StockUsage(stock.Id, count, stock.Quantity - count); var bottom = fill.Parts.Min(p => p.Bottom);
}); return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete; fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList());
} }
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason) private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
+6 -124
View File
@@ -1,7 +1,5 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.Opus55; namespace OpenNest.Engine.Opus55;
@@ -56,8 +54,6 @@ internal static class PartCatalog
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary> /// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
private const int MaxOrientations = 8; private const int MaxOrientations = 8;
private const double TwoPi = System.Math.PI * 2;
public static IReadOnlyList<PartType> Build(NestJob job) public static IReadOnlyList<PartType> Build(NestJob job)
{ {
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation) // Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
@@ -83,21 +79,15 @@ internal static class PartCatalog
continue; continue;
} }
var angles = CandidateAngles(part.Rotation, perimeter, perType); var angles = RotationCandidates.DistinctOutlines(perimeter,
CandidateAngles(part.Rotation, perimeter, perType));
var tolerance = ChooseTolerance(perimeter); var tolerance = ChooseTolerance(perimeter);
var orientations = new List<Orientation>(); var orientations = new List<Orientation>();
var signatures = new List<string>();
foreach (var angle in angles) foreach (var angle in angles)
{ {
var outline = Polygonize(perimeter, angle, tolerance); var outline = Polygonize(perimeter, angle, tolerance);
if (outline.Count < 3) if (outline.Count < 3)
continue; 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)); orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
} }
@@ -107,17 +97,8 @@ internal static class PartCatalog
return types; return types;
} }
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
{ JobPartGeometry.TryRead(geometry)?.Perimeter;
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;
}
/// <summary> /// <summary>
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small /// 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) internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
{ RotationCandidates.ForShape(policy, perimeter, limit).ToList();
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;
}
} }
+25 -15
View File
@@ -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). 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 - 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 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 - 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 round offset) by `spacing / 2 + NestTolerances.SafeClearanceMargin(chordTolerance) / 2`
when their footprints don't overlap. The 0.022 covers validators that polygonize arcs plus four Clipper grid units per footprint. The extra grid allowance preserves this
circumscribed at 0.01 per side, plus Clipper's 1e-4 grid. 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) - **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₀). 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. 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 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). 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. 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 1. **Gap fill:** if any part fits without pushing the packing front forward, place the
*largest* such part at its lowest point. *largest* such part at its lowest point.
2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the 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. 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. - 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 - 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 *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 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 | | `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule |
| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) | | `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) |
| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation | | `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation |
| `SheetEconomics.cs` | Net-area objective with salvage credit | | `tests/` | xUnit suite. Layouts are judged by `Engine.Testing.LayoutAssert` and `NestLayoutCheck` |
| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` |
## Build / test ## 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 dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
``` ```
This project is intentionally **outside** `OpenNest.sln`, the same pattern as the This project is intentionally **outside** `OpenNest.sln`. It's discovered at runtime as a plugin.
`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin.
## Benchmark ## Benchmark
@@ -84,14 +83,25 @@ The engine reports as `Opus55NestingEngine`.
## Known limitations ## Known limitations
- **No part-in-part:** holes are treated as solid, so small parts never nest inside cutouts. - **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 - **Clearance padding:** gaps are ~0.003 (plus up to the chord tolerance) wider than the
required spacing, to stay valid under circumscribed-polygon validators. That's negligible in mm required spacing, to stay valid under `NestValidator`'s 0.001 arc flattening. The margin was
and about 0.02" in inches. The constants are absolute and assume job units near inch/mm scale. 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 - **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. 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 - **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. search, and density on small mixed jobs trails what an interlocking-pair filler can reach.
- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate` - **Priority is enforced during placement** (lower number first). Progress reports
per trial and `PlateCommitted` at the end, with no finer-grained progress. `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 - 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. 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).
-41
View File
@@ -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>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.Opus55.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> </ItemGroup>
</Project> </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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
@@ -18,7 +20,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count); Assert.Equal(12, result.Plates[0].Placements.Count);
@@ -44,7 +46,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -55,7 +57,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -70,7 +72,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -84,7 +86,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("small", Assert.Single(result.Plates).StockId); Assert.Equal("small", Assert.Single(result.Plates).StockId);
} }
@@ -96,7 +98,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1); Assert.True(result.Plates.Count > 1);
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count)); Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
@@ -120,7 +122,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
foreach (var placement in result.Plates.SelectMany(p => p.Placements)) foreach (var placement in result.Plates.SelectMany(p => p.Placements))
{ {
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep; var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
@@ -138,7 +140,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status); Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
@@ -152,7 +154,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason); Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(2, result.Plates.Count); Assert.Equal(2, result.Plates.Count);
var usage = Assert.Single(result.StockUsage); var usage = Assert.Single(result.StockUsage);
@@ -171,7 +173,7 @@ public class Opus55NestingEngineTests
var result = new Opus55NestingEngine().Solve(job); var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason); Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
} }
@@ -188,7 +190,25 @@ public class Opus55NestingEngineTests
var first = new Opus55NestingEngine().Solve(Build()); var first = new Opus55NestingEngine().Solve(Build());
var second = 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] [Fact]
@@ -198,86 +218,6 @@ public class Opus55NestingEngineTests
Assert.IsAssignableFrom<INestingEngine>(engine); 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);
}
}
+128
View File
@@ -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> { }
+11 -3
View File
@@ -9,6 +9,7 @@ the OpenNest app or `OpenNest.Benchmark` build output.
|--------|----------| |--------|----------|
| [Gpt6Astra](OpenNest.Engine.Gpt6Astra/) | Contact-based placement | | [Gpt6Astra](OpenNest.Engine.Gpt6Astra/) | Contact-based placement |
| [Opus55](OpenNest.Engine.Opus55/) | Frontier-advance no-fit-polygon packing | | [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 ## 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. 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 ## Writing a new engine
```powershell ```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 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 spelling out what counts as an independent engine, `BENCH-RULES.md` (how a model's run
the benchmark's own `NestValidator` (they fail until `Solve()` is implemented). 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 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 ```powershell
./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles ./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles
+67
View File
@@ -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
View File
@@ -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 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. 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 ## Allowed building blocks
Reuse is encouraged. These are tools you drive, composed by your own decision logic: 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`, - `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`, `ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
`SpatialQuery`. `SpatialQuery`.
@@ -43,10 +50,12 @@ measured numbers) so it can be generalized and upstreamed for every engine later
## Tests ## Tests
`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own `tests/` references the read-only `../Engine.Testing` kit and subclasses
`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the `EngineContractTests<TEngine>`. `LayoutAssert.Valid` uses `NestLayoutCheck.Violations`,
benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and the benchmark's shared validation primitive, plus strict bounds and accounting checks.
add engine-specific tests next to them. 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 ```bash
dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj
+7 -3
View File
@@ -18,6 +18,7 @@ public sealed class __NAME__NestingEngine : INestingEngine
) )
{ {
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
// TODO: implement independent placement logic here. // 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.Plates -> candidate stock sheets (size, spacing, edge spacing, quadrant, quantity)
// job.Options -> job-wide options // job.Options -> job-wide options
// //
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding // Read material with JobPartGeometry.TryRead(part.Geometry); use stock.WorkArea/Fits,
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id), // RotationCandidates.ForShape and NestJobCost as primitives for your own decisions.
// and StockUsage (sheets used per stock id). // 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."); throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented.");
} }
@@ -10,8 +10,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.__NAME__.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> </ItemGroup>
</Project> </Project>
+10 -67
View File
@@ -1,7 +1,9 @@
using OpenNest.Engine.Testing;
using static OpenNest.Engine.Testing.JobBuilder;
using static OpenNest.Engine.Testing.Shapes;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
@@ -10,7 +12,7 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.__NAME__.Tests; namespace OpenNest.Engine.__NAME__.Tests;
/// <summary> /// <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 /// 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. /// Solve() is implemented; add engine-specific tests alongside them.
/// </summary> /// </summary>
@@ -30,7 +32,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count); Assert.Equal(12, result.Plates[0].Placements.Count);
@@ -55,7 +57,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -66,7 +68,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1); Assert.True(result.Plates.Count > 1);
} }
@@ -81,70 +83,11 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced); 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> { }