chore: move plugin engines to the OpenNest-Engines repo

Engines now live in https://git.thecozycat.net/aj/OpenNest-Engines (history
carried over) so they can be published independently, and so a copy of
OpenNest handed to a model for an engine-building run contains no
competing engines. Engines still load at runtime from an Engines/ folder
next to the app/benchmark output; nothing in the solution referenced them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-24 10:02:29 -04:00
co-authored by Claude Opus 5.5
parent 22d21924b8
commit e3d10e95ae
47 changed files with 2 additions and 3507 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ Nesting algorithms use the jobs-only API. `INestingEngine.Solve(NestJob)` return
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester`, `StripPlateNester`, and `RemnantPlateNester` are built-ins with run-scoped private geometry. `PlateFillService` is the public single-plate proposal service for interactive fill/group/pack flows; it returns parts without mutating caller-owned plates. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` retains name-based helpers and remains outside the runner path.
- **Filler pipeline (`Jobs/Placement/Fillers/`)**: internal `DefaultPlateFiller`, `StripPlateFiller`, and policy-backed `RemnantPlateFiller` implement the standard single-plate geometry pipeline. `Default` runs the Linear, Pairs, RectBestFit, and Extents phases; remnant variants preserve their distinct comparer, direction, trim-axis, and angle-ordering policies.
- **Engine registration**: `NestingEngineRegistry` holds whole-job `INestingEngine` implementations including the four fixed strategies and `StockLadder`. It loads plug-ins that implement `INestingEngine` and have a public parameterless constructor. Plug-ins for the removed single-plate inheritance API are not binary compatible.
- **In-repo plugin engines (`Engines/`)**: each lives in `Engines/OpenNest.Engine.<Name>/` with an optional `tests/` subproject, outside `OpenNest.sln`. `Engines/Directory.Build.props` supplies the TFM, nullable/implicit usings, and the `OpenNest.Engine` reference, so an engine csproj only adds what is unique to it; `Engines/Directory.Build.targets` excludes `tests/**` from the engine compile (it must be a `.targets` file to run after the SDK's default Compile glob). `./Engines/Build-Engines.ps1 [-Engines Name1,Name2]` builds the benchmark and engines and deploys the DLLs into `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`. Add new engines here, not at the repo root.
- **Plugin engines**: independent `INestingEngine` plugins are class libraries that reference `OpenNest.Engine` and are built outside `OpenNest.sln`. The desktop app and `OpenNest.Benchmark` load them from an `Engines/` folder next to their build output (e.g. `OpenNest.Benchmark/bin/<Config>/net8.0/Engines/`). Do not add engine projects to this repo.
- **IFillComparer**: Interface enabling filler-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), and `HorizontalRemnantComparer` (minimize Y-extent) are grouped into `FillPolicy` on `FillContext`.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
-40
View File
@@ -1,40 +0,0 @@
<#
.SYNOPSIS
Builds every plugin engine under Engines/ and deploys it to the benchmark.
.DESCRIPTION
OpenNest.Benchmark loads plugin engines from an Engines/ folder next to its own
build output. This builds the benchmark plus each Engines/OpenNest.Engine.*/ project
(test subprojects are skipped) and copies each engine DLL into that folder.
.EXAMPLE
./Engines/Build-Engines.ps1
./Engines/Build-Engines.ps1 -Engines Opus55,Terra -Configuration Debug
#>
param(
[string]$Configuration = 'Release',
# Engine names without the OpenNest.Engine. prefix; default is all of them.
[string[]]$Engines
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path $PSScriptRoot -Parent
dotnet build (Join-Path $repoRoot 'OpenNest.Benchmark/OpenNest.Benchmark.csproj') -c $Configuration
if ($LASTEXITCODE -ne 0) { throw 'OpenNest.Benchmark build failed.' }
$deployDir = Join-Path $repoRoot "OpenNest.Benchmark/bin/$Configuration/net8.0/Engines"
New-Item -ItemType Directory -Force $deployDir | Out-Null
$projects = Get-ChildItem $PSScriptRoot -Directory -Filter 'OpenNest.Engine.*' |
Where-Object { -not $Engines -or $Engines -contains $_.Name.Substring('OpenNest.Engine.'.Length) }
foreach ($dir in $projects) {
$csproj = Join-Path $dir.FullName "$($dir.Name).csproj"
dotnet build $csproj -c $Configuration
if ($LASTEXITCODE -ne 0) { throw "$($dir.Name) build failed." }
$dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll"
Copy-Item $dll $deployDir -Force
Write-Host "Deployed $($dir.Name) -> $deployDir"
}
-15
View File
@@ -1,15 +0,0 @@
<Project>
<!--
Shared settings for out-of-solution nesting engine plugins. Each engine lives in
Engines/OpenNest.Engine.<Name>/ with an optional tests/ subproject; both import this.
AssemblyName and RootNamespace default to the project file name.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(MSBuildThisFileDirectory)../OpenNest.Engine/OpenNest.Engine.csproj" />
</ItemGroup>
</Project>
-10
View File
@@ -1,10 +0,0 @@
<Project>
<!--
Each engine's tests/ subproject sits inside the engine folder. This must live in a
.targets file: removals in Directory.Build.props run before the SDK adds its default
Compile glob, so they would have no effect.
-->
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
</ItemGroup>
</Project>
@@ -1,150 +0,0 @@
using OpenNest.Engine.Jobs;
using M = System.Math;
namespace OpenNest.Engine.Astra;
/// <summary>Independent configuration-space contact packing with bounded stock-plan search.</summary>
public sealed class AstraNestingEngine : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
NestJobValidator.Validate(job);
var parts = GeometryPreparation.Prepare(job, token);
var fit = parts.Select(p => job.Plates.Select(s => p.Variants.Any(v =>
v.Width <= s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right + 1e-9 &&
v.Height <= s.Size.Width - s.EdgeSpacing.Top - s.EdgeSpacing.Bottom + 1e-9)).ToArray()).ToArray();
var placer = new ContactPlacer(parts, new ContactGeometry(), token);
var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
var frontier = new List<Plan> { initial };
var best = initial;
Plan? complete = IsComplete(initial) ? initial : null;
var trials = new Dictionary<string, SheetTrial>(StringComparer.Ordinal);
var priorities = job.Parts.Select(p => p.Priority).Distinct().OrderDescending().ToArray();
var evaluated = 0;
var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray();
while (frontier.Count > 0)
{
token.ThrowIfCancellationRequested();
var children = new Dictionary<string, Plan>(StringComparer.Ordinal);
foreach (var state in frontier)
{
if (state.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue)) continue;
var lowerBound = LowerBound(state);
if (complete != null && lowerBound >= complete.Cost - 1e-7) continue;
var flexibility = Enumerable.Range(0, parts.Length).Select(p =>
Enumerable.Range(0, job.Plates.Count).Count(s => fit[p][s] && Available(state, s))).ToArray();
for (var s = 0; s < job.Plates.Count; s++)
{
if (!Available(state, s)) continue;
// Search breadth is work-count bounded, never elapsed-time dependent.
// Past this budget, continue filling greedily instead of abandoning demand.
var modes = evaluated < 24 ? 2 : 1;
for (var mode = 0; mode < modes; mode++)
{
token.ThrowIfCancellationRequested();
var key = $"{s}/{mode}/{string.Join(',', state.Counts)}/{string.Join(',', flexibility)}";
if (!trials.TryGetValue(key, out var trial))
{
progress?.Report(new(NestJobStage.EvaluatingCandidate, job.Plates[s].Id,
state.Sheets.Count, 0, 0));
trial = placer.Pack(s, job.Plates[s], state.Counts, flexibility, mode);
if (trials.Count >= 256) trials.Clear();
trials[key] = trial;
evaluated++;
}
if (trial.Shapes.Count == 0) continue;
var sheetCost = job.Plates[s].Size.Length * job.Plates[s].Size.Width;
for (var p = 0; p < parts.Length; p++)
{
var delivered = trial.Counts[p] - state.Counts[p];
if (delivered > 0) unitCosts[p] = M.Min(unitCosts[p], sheetCost / delivered);
}
var used = (int[])state.Used.Clone(); used[s]++;
var sheets = new List<SheetTrial>(state.Sheets) { trial };
var next = new Plan(trial.Counts, used, sheets,
state.Cost + job.Plates[s].Size.Length * job.Plates[s].Size.Width);
if (BetterFulfillment(next, best)) best = next;
if (IsComplete(next))
{
if (complete == null || next.Cost < complete.Cost - 1e-7 ||
(M.Abs(next.Cost - complete.Cost) < 1e-7 && next.Sheets.Count < complete.Sheets.Count)) complete = next;
continue;
}
var stateKey = $"{string.Join(',', next.Counts)}/{string.Join(',', next.Used)}";
if (!children.TryGetValue(stateKey, out var prior) || next.Cost < prior.Cost)
children[stateKey] = next;
}
}
}
var ranked = children.Values.Where(p => complete == null || LowerBound(p) < complete.Cost - 1e-7)
.OrderBy(Estimate).ThenByDescending(PlacedArea).ThenBy(p => p.Cost).ToList();
frontier = new List<Plan>();
if (ranked.Count > 0)
{
frontier.Add(ranked[0]);
// A material-only lower bound favors cheap small-sheet prefixes and
// can discard every high-throughput plan. Preserve one progress leader.
var leader = ranked.OrderByDescending(PlacedArea).ThenBy(p => p.Cost).First();
if (!ReferenceEquals(leader, ranked[0])) frontier.Add(leader);
foreach (var candidate in ranked)
{
if (frontier.Count >= (evaluated < 64 ? 3 : 2)) break;
if (!frontier.Contains(candidate)) frontier.Add(candidate);
}
}
}
var selected = complete ?? best;
var counts = new int[parts.Length];
var plates = new List<NestJobPlateResult>();
foreach (var sheet in selected.Sheets)
{
token.ThrowIfCancellationRequested();
var stock = job.Plates[sheet.StockIndex];
var x = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
var y = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
var placements = sheet.Shapes.Select(p => new NestJobPlacement(job.Parts[p.Variant.Part].Id,
counts[p.Variant.Part]++, x + p.X - p.Variant.OriginX,
y + p.Y - p.Variant.OriginY, p.Variant.Angle)).ToArray();
plates.Add(new(plates.Count, stock, placements));
progress?.Report(new(NestJobStage.PlateCommitted, stock.Id, plates.Count - 1,
plates.Count, counts.Sum()));
}
token.ThrowIfCancellationRequested();
var reason = complete != null ? NestJobStopReason.Completed :
selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached :
!Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted :
NestJobStopReason.NoPlacementFound;
return new(complete != null ? NestJobStatus.Complete : NestJobStatus.Incomplete, reason, plates,
job.Parts.Select((p, i) => new PartFulfillment(p.Id, p.Quantity, counts[i], p.Quantity - counts[i])),
job.Plates.Select((s, i) => new StockUsage(s.Id, selected.Used[i], s.Quantity - selected.Used[i])));
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);
double PlacedArea(Plan p) => parts.Select((part, i) => p.Counts[i] * part.Area).Sum();
double LowerBound(Plan p) => p.Cost + parts.Select((part, i) =>
(part.Requirement.Quantity - p.Counts[i]) * part.Area).Sum();
double Estimate(Plan p)
{
var projected = 0.0;
for (var i = 0; i < parts.Length; i++)
if (double.IsFinite(unitCosts[i])) projected = M.Max(projected,
(parts[i].Requirement.Quantity - p.Counts[i]) * unitCosts[i]);
return M.Max(LowerBound(p), p.Cost + projected);
}
bool BetterFulfillment(Plan a, Plan b)
{
foreach (var priority in priorities)
{
var ac = parts.Select((p, i) => p.Requirement.Priority == priority ? a.Counts[i] : 0).Sum();
var bc = parts.Select((p, i) => p.Requirement.Priority == priority ? b.Counts[i] : 0).Sum();
if (ac != bc) return ac > bc;
}
return a.Cost < b.Cost;
}
}
private sealed record Plan(int[] Counts, int[] Used, List<SheetTrial> Sheets, double Cost);
}
@@ -1,64 +0,0 @@
using Clipper2Lib;
using OpenNest.Geometry;
using M = System.Math;
namespace OpenNest.Engine.Astra;
/// <summary>Per-solve configuration-space cache, never a shared mutable geometry cache.</summary>
internal sealed class ContactGeometry
{
private readonly Dictionary<(int, int, double), PathsD> cache = new();
internal PathsD Forbidden(ShapeVariant stationary, ShapeVariant moving, double spacing, CancellationToken token)
{
var key = (stationary.Id, moving.Id, spacing);
if (cache.TryGetValue(key, out var value)) return value;
token.ThrowIfCancellationRequested();
PathsD paths;
if (stationary.BoxLike && moving.BoxLike)
{
// Exact axis-aligned rectangle contacts need four configuration-space
// vertices, not hundreds of round-offset samples. The square corner is
// conservative for diagonal clearance and leaves row/column fits exact.
var gap = spacing;
paths = new PathsD { new PathD {
new(-moving.Width - gap, -moving.Height - gap),
new(stationary.Width + gap, -moving.Height - gap),
new(stationary.Width + gap, stationary.Height + gap),
new(-moving.Width - gap, stationary.Height + gap) } };
if (cache.Count >= 8192) cache.Clear();
return cache[key] = paths;
}
if (stationary.Convex && moving.Convex)
{
var nfp = NoFitPolygon.ComputeConvex(stationary.Hull, moving.Hull);
paths = new PathsD { ClipperBridge.ToPath(nfp, positive: true) };
}
else
{
// Minkowski edge quads may enclose spurious interior voids. Filling all
// positive outer paths is conservative for solid perimeter nesting; real
// part holes are searched separately and checked against material regions.
var a = ToInteger(stationary.ContactOutline, false);
var b = ToInteger(moving.ContactOutline, true);
var sum = Clipper.MinkowskiSum(b, a, true);
paths = new PathsD(sum.Where(Clipper.IsPositive).Select(path => new PathD(
path.Select(p => new PointD(p.X / GeometryPrecision.Scale, p.Y / GeometryPrecision.Scale)))));
}
token.ThrowIfCancellationRequested();
var delta = spacing + stationary.ContactError + moving.ContactError
+ (stationary.Curved || moving.Curved ? 0.003 : spacing > 0 ? 0.0003 : 0);
if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round,
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
// Bound cache residency for jobs with many distinct rotation pairs.
if (cache.Count >= 8192) cache.Clear();
return cache[key] = paths;
}
private static Path64 ToInteger(Polygon polygon, bool reflect)
{
var scale = reflect ? -GeometryPrecision.Scale : GeometryPrecision.Scale;
var path = ClipperBridge.ToPath(polygon, positive: true);
return new Path64(path.Select(p => new Point64((long)M.Round(p.x * scale), (long)M.Round(p.y * scale))));
}
}
@@ -1,268 +0,0 @@
using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using M = System.Math;
namespace OpenNest.Engine.Astra;
internal sealed record PackedShape(ShapeVariant Variant, double X, double Y);
internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape> Shapes, double Area, double Span);
/// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token)
{
private readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new();
private double validationOriginX;
private double validationOriginY;
internal SheetTrial Pack(int stockIndex, NestPlateStock stock, int[] committed, int[] flexibility, int mode)
{
validationOriginX = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
validationOriginY = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
var width = stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right;
var height = stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top;
var counts = (int[])committed.Clone();
var placed = new List<PackedShape>();
var spaces = new Dictionary<int, SearchSpace>();
var order = Enumerable.Range(0, parts.Length)
.OrderByDescending(i => parts[i].Requirement.Priority)
.ThenBy(i => flexibility[i])
.ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min())
.ThenBy(i => i).ToArray();
double area = 0, right = 0, top = 0;
foreach (var p in order)
{
while (counts[p] < parts[p].Requirement.Quantity)
{
token.ThrowIfCancellationRequested();
PackedShape? best = null;
(double, double, double, double) bestScore = (double.MaxValue, 0, 0, 0);
foreach (var v in parts[p].Variants)
{
token.ThrowIfCancellationRequested();
if (v.Width > width + 1e-9 || v.Height > height + 1e-9) continue;
var pose = Find(v, placed, width, height, stock.PartSpacing, mode, right, top, spaces);
if (pose == null) continue;
var score = Score(pose, width, height, mode, right, top);
if (score.CompareTo(bestScore) < 0) { best = pose; bestScore = score; }
}
if (best == null) break;
placed.Add(best);
counts[p]++;
area += parts[p].Area;
right = M.Max(right, best.X + best.Variant.Width);
top = M.Max(top, best.Y + best.Variant.Height);
}
}
return new(stockIndex, counts, placed, area, right * top);
}
private PackedShape? Find(ShapeVariant moving, List<PackedShape> placed, double width, double height,
double spacing, int mode, double right, double top, Dictionary<int, SearchSpace> spaces)
{
var maxX = M.Max(0, width - moving.Width);
var maxY = M.Max(0, height - moving.Height);
if (!spaces.TryGetValue(moving.Id, out var space))
{
space = new SearchSpace();
space.Anchors.AddRange(new PointD[] { new(0, 0), new(maxX, 0), new(0, maxY), new(maxX, maxY) });
space.Free.Add(new PathD { new(0, 0), new(maxX, 0), new(maxX, maxY), new(0, maxY) });
spaces.Add(moving.Id, space);
}
var points = space.Anchors;
var forbidden = new PathsD();
var blockers = space.Blockers;
foreach (var other in placed.Skip(space.Processed))
{
token.ThrowIfCancellationRequested();
var paths = GeometryPrecision.Translate(geometry.Forbidden(other.Variant, moving, spacing, token), other.X, other.Y);
foreach (var path in paths)
{
forbidden.Add(path);
if (!other.Variant.Material.Any(p => !Clipper.IsPositive(p)))
blockers.Add((path, path.Min(p => p.x), path.Min(p => p.y), path.Max(p => p.x), path.Max(p => p.y)));
// Clipping loses zero-area feasible regions. Retain their NFP vertices
// and intersections with plate boundaries explicitly for exact fits.
for (var i = 0; (maxX < 1e-8 || maxY < 1e-8) && i < path.Count; i++)
{
var a = path[i]; var b = path[(i + 1) % path.Count];
Add(a.x, a.y);
CrossX(0); CrossX(maxX); CrossY(0); CrossY(maxY);
void CrossX(double x)
{
if (M.Abs(b.x - a.x) < 1e-12) return;
var t = (x - a.x) / (b.x - a.x);
if (t >= 0 && t <= 1) Add(x, a.y + t * (b.y - a.y));
}
void CrossY(double y)
{
if (M.Abs(b.y - a.y) < 1e-12) return;
var t = (y - a.y) / (b.y - a.y);
if (t >= 0 && t <= 1) Add(a.x + t * (b.x - a.x), y);
}
}
}
// Axis contacts also cover exact spacing when the padded NFP cannot fit.
foreach (var x in new[] { other.X, other.X + other.Variant.Width + spacing,
other.X - moving.Width - spacing })
foreach (var y in new[] { 0, other.Y, other.Y + other.Variant.Height + spacing,
other.Y - moving.Height - spacing }) Add(x, y);
// The solid-outline NFP deliberately fills holes. Search each real hole
// separately, then validate against material, not the outer envelope.
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p)))
{
var l = hole.Min(p => p.x) + other.X + spacing + 0.0004;
var b = hole.Min(p => p.y) + other.Y + spacing + 0.0004;
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - 0.0004;
var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - 0.0004;
if (r < l || t < b) continue;
Add(l, b); Add(r, b); Add(l, t); Add(r, t); Add((l + r) / 2, (b + t) / 2);
// Box corners miss the useful interior of circular and rounded holes.
// Interior samples also cover fits that require an off-center placement.
foreach (var fx in new[] { 0.25, 0.5, 0.75 })
foreach (var fy in new[] { 0.25, 0.5, 0.75 }) Add(l + fx * (r - l), b + fy * (t - b));
}
}
if (placed.Count > 0 && maxX > 1e-8 && maxY > 1e-8)
{
space.Free = Clipper.Difference(space.Free, forbidden, FillRule.NonZero, GeometryPrecision.Digits);
}
space.Processed = placed.Count;
points = new List<PointD>(space.Anchors);
foreach (var path in space.Free) foreach (var p in path) Add(p.x, p.y);
var seen = new HashSet<(long, long)>();
foreach (var pose in points.Select(p => new PackedShape(moving, p.x, p.y))
.OrderBy(p => Score(p, width, height, mode, right, top)))
{
token.ThrowIfCancellationRequested();
if (!seen.Add(((long)M.Round(pose.X * 1e6), (long)M.Round(pose.Y * 1e6)))) continue;
if (blockers.Any(b => pose.X > b.L && pose.X < b.R && pose.Y > b.B && pose.Y < b.T &&
StrictlyInside(b.Path, pose.X, pose.Y))) continue;
if (Valid(pose, placed, spacing)) return pose;
if (spacing > 0) continue;
// Exact contacts can be invalid only after the host's four-decimal
// polygon rounding. Try nearby outward contacts without changing angle.
foreach (var (dx, dy) in new (double, double)[] {
(0.0003, 0), (0, 0.0003), (0.0003, 0.0003), (-0.0003, 0),
(0, -0.0003), (-0.0003, 0.0003), (0.0003, -0.0003), (-0.0003, -0.0003) })
{
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 (Valid(nudged, placed, spacing)) return nudged;
}
}
return null;
void Add(double x, double y)
{
if (x < -1e-7 || y < -1e-7 || x > maxX + 1e-7 || y > maxY + 1e-7) return;
points.Add(new(M.Clamp(x, 0, maxX), M.Clamp(y, 0, maxY)));
}
}
private static (double, double, double, double) Score(PackedShape pose, double width, double height,
int mode, double right, double top)
{
var r = pose.X + pose.Variant.Width;
var t = pose.Y + pose.Variant.Height;
// Two directional searches use the same configuration-space algorithm. The
// third objective minimizes the growing used rectangle rather than a strip.
return mode switch
{
1 => (r + 0.01 * t * width / height, pose.Y, pose.X, pose.Variant.Width * pose.Variant.Height),
2 => (M.Max(right, r) * M.Max(top, t), t, r, pose.Variant.Width * pose.Variant.Height),
_ => (t + 0.01 * r * height / width, pose.X, pose.Y, pose.Variant.Width * pose.Variant.Height)
};
}
private sealed class SearchSpace
{
internal int Processed;
internal PathsD Free = new();
internal List<PointD> Anchors = new();
internal List<(PathD Path, double L, double B, double R, double T)> Blockers = new();
}
private static bool StrictlyInside(PathD path, double x, double y)
{
var inside = false;
for (var i = 0; i < path.Count; i++)
{
var a = path[i]; var b = path[(i + 1) % path.Count];
var cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x);
if (M.Abs(cross) <= 2e-6 * M.Max(1, M.Abs(b.x - a.x) + M.Abs(b.y - a.y)) &&
x >= M.Min(a.x, b.x) - 1e-6 && x <= M.Max(a.x, b.x) + 1e-6 &&
y >= M.Min(a.y, b.y) - 1e-6 && y <= M.Max(a.y, b.y) + 1e-6) return false;
if ((a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
}
private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
{
PathsD? material = null;
PathsD? validationMaterial = null;
foreach (var other in placed)
{
var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? 0.003 : 0.0001);
if (candidate.X >= other.X + other.Variant.Width + gap ||
other.X >= candidate.X + candidate.Variant.Width + gap ||
candidate.Y >= other.Y + other.Variant.Height + gap ||
other.Y >= candidate.Y + candidate.Variant.Height + gap) continue;
token.ThrowIfCancellationRequested();
if (candidate.Variant.BoxLike && other.Variant.BoxLike)
{
if (candidate.X >= other.X + other.Variant.Width + spacing - 1e-9 ||
other.X >= candidate.X + candidate.Variant.Width + spacing - 1e-9 ||
candidate.Y >= other.Y + other.Variant.Height + spacing - 1e-9 ||
other.Y >= candidate.Y + candidate.Variant.Height + spacing - 1e-9) continue;
return false;
}
material ??= GeometryPrecision.Translate(candidate.Variant.Material, candidate.X, candidate.Y);
var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false;
validationMaterial ??= GeometryPrecision.Translate(candidate.Variant.ValidationRegion(0), candidate.X, candidate.Y);
var validationObstacle = GeometryPrecision.Translate(other.Variant.ValidationRegion(spacing), other.X, other.Y);
if (M.Abs(Clipper.Area(Clipper.Intersect(validationMaterial, validationObstacle,
FillRule.NonZero, GeometryPrecision.Digits))) > 1e-8) return false;
if (spacing == 0 || candidate.Variant.Material.Count > 1 || other.Variant.Material.Count > 1)
{
var outerIntersection = Clipper.Intersect(
new PathsD(validationMaterial.Where(Clipper.IsPositive)),
new PathsD(validationObstacle.Where(Clipper.IsPositive)), FillRule.NonZero, GeometryPrecision.Digits);
if (spacing != 0 && M.Abs(Clipper.Area(outerIntersection)) <= 1e-8) continue;
var key = (candidate.Variant.Id, other.Variant.Id, spacing,
candidate.X + validationOriginX, candidate.Y + validationOriginY,
other.X + validationOriginX, other.Y + validationOriginY);
if (!validationCache.TryGetValue(key, out var collides))
{
collides = ValidationOverlap(
GeometryPrecision.Translate(validationMaterial, validationOriginX, validationOriginY),
GeometryPrecision.Translate(validationObstacle, validationOriginX, validationOriginY));
if (validationCache.Count >= 4096) validationCache.Clear();
validationCache[key] = collides;
}
if (collides) return false;
}
}
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;
}
}
@@ -1,6 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<Compile Remove="tests/**/*.cs;benchmarks/**/*.cs" />
<ProjectReference Include="../../OpenNest.Core/OpenNest.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,187 +0,0 @@
using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
using M = System.Math;
namespace OpenNest.Engine.Astra;
internal sealed record PreparedPart(NestJobPart Requirement, double Area, ShapeVariant[] Variants);
internal sealed class ShapeVariant
{
internal required int Id { get; init; }
internal required int Part { get; init; }
internal required double Angle { get; init; }
internal required double OriginX { get; init; }
internal required double OriginY { get; init; }
internal required double Width { get; init; }
internal required double Height { get; init; }
internal required bool Curved { get; init; }
internal required PathsD Material { get; init; }
internal required Polygon Outline { get; init; }
internal required Polygon ContactOutline { get; init; }
internal required double ContactError { get; init; }
internal required Polygon Hull { get; init; }
internal required bool Convex { get; init; }
internal required ShapeProfile ValidationProfile { get; init; }
internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
GridAligned(Width) && GridAligned(Height) &&
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * Height);
private static bool GridAligned(double x) => M.Abs(x - M.Round(x * 10000) / 10000) < 1e-9;
private readonly Dictionary<double, PathsD> validationRegions = new();
internal PathsD ValidationRegion(double spacing)
{
if (validationRegions.TryGetValue(spacing, out var cached)) return cached;
// Match the external validator's sequence: flatten/round in the original
// rotated snapshot frame, then translate. Rounding after normalization is
// not equivalent at a zero-clearance contact.
var region = ClipperBridge.OffsetForValidation(ValidationProfile, spacing, 0.001);
var paths = new PathsD(region.Outers.Select(p => ClipperBridge.ToPath(p, true)));
paths.AddRange(region.Holes.Select(p => ClipperBridge.ToPath(p, false)));
return validationRegions[spacing] = GeometryPrecision.Translate(paths, -OriginX, -OriginY);
}
private readonly Dictionary<double, PathsD> halos = new();
internal PathsD Halo(double spacing)
{
if (halos.TryGetValue(spacing, out var cached)) return cached;
// Raw outlines already circumscribe curves; the extra clearance covers independent
// flattenings after pose materialization and the validator's four-decimal grid.
var delta = spacing + (Curved ? 0.0021 : spacing > 0 ? 0.00015 : 0);
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
}
}
internal static class GeometryPrecision
{
internal const int Digits = 6;
internal const double Scale = 1_000_000;
internal const double Epsilon = 0.000002;
internal static PathsD Translate(PathsD paths, double x, double y) =>
new(paths.Select(path => new PathD(path.Select(p => new PointD(p.x + x, p.y + y)))));
internal static PathsD FromPolygons(IEnumerable<Polygon> polygons, bool positive) =>
new(polygons.Select(p => ClipperBridge.ToPath(p, positive)));
}
internal static class GeometryPreparation
{
internal static PreparedPart[] Prepare(NestJob job, CancellationToken token)
{
var id = 0;
return job.Parts.Select((part, index) =>
{
token.ThrowIfCancellationRequested();
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToList();
// Input validation has established that open marks lie inside material. They
// must not be interpreted as holes by ShapeProfile.
var closed = ShapeBuilder.GetShapes(entities).Where(s => s.IsClosed())
.SelectMany(s => s.Entities).ToList();
var baseProfile = new ShapeProfile(closed);
var area = baseProfile.Perimeter.Area() - baseProfile.Cutouts.Sum(h => h.Area());
var variants = new List<ShapeVariant>();
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var angle in Angles(part.Rotation, baseProfile))
{
token.ThrowIfCancellationRequested();
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList();
var x = rotated.Min(e => e.Left);
var y = rotated.Min(e => e.Bottom);
var w = rotated.Max(e => e.Right) - x;
var h = rotated.Max(e => e.Top) - y;
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
throw new ArgumentException($"Unusable rotated bounds: {part.Id}.");
var validationProfile = new ShapeProfile(rotated.Select(e => e.Clone()).ToList());
foreach (var e in rotated) e.Offset(-x, -y);
var profile = new ShapeProfile(rotated);
var material = ClipperBridge.ToRegion(profile, 0.001, circumscribe: true);
// Circular/symmetric parts should not multiply identical NFP work. Compare
// normalized closed contours, including holes, independent of start vertex.
var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal));
if (!keys.Add(key)) continue;
var outline = ClipperBridge.Flatten(profile.Perimeter, 0.001, circumscribe: true);
var hull = ConvexHull.Compute(outline.Vertices);
var convex = M.Abs(hull.Area() - outline.Area()) < 1e-7 * M.Max(1, hull.Area());
// Concave Minkowski sums have quadratic input size. Only the contact
// proposal outline is simplified; fine material remains the safety gate.
// Pad the resulting NFP by both approximation error bounds.
var contactError = !convex && outline.Vertices.Count > 64 ? M.Max(0.002, M.Min(w, h) * 0.002) : 0;
var contactOutline = contactError == 0 ? outline :
ClipperBridge.Flatten(profile.Perimeter, contactError, circumscribe: true);
variants.Add(new ShapeVariant { Id = id++, Part = index, Angle = angle,
OriginX = x, OriginY = y, Width = w, Height = h,
Curved = rotated.Any(e => e is Arc or Circle), Material = material,
Outline = outline, ContactOutline = contactOutline, ContactError = contactError,
Hull = hull, Convex = convex, ValidationProfile = validationProfile });
}
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
{
var minimum = ordered[0].Width * ordered[0].Height;
var all = ordered;
var shortlist = ordered.Where(v => v.Width * v.Height <= minimum * 1.08 + 1e-7).Take(16).ToList();
// A diagonal may be the only orientation fitting a narrow stock. Never
// discard every fitting orientation merely because its envelope is larger.
foreach (var stock in job.Plates)
{
bool Fits(ShapeVariant v) => v.Width <= stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right + 1e-9 &&
v.Height <= stock.Size.Width - stock.EdgeSpacing.Top - stock.EdgeSpacing.Bottom + 1e-9;
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
}
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
}
return new PreparedPart(part, area, ordered);
}).ToArray();
}
private static string Canonical(PathD path)
{
if (path.Count == 0) return "";
var points = path.Select(p => ((long)M.Round(p.x * 100000), (long)M.Round(p.y * 100000))).ToArray();
var first = 0;
for (var i = 1; i < points.Length; i++) if (points[i].CompareTo(points[first]) < 0) first = i;
return string.Join(";", Enumerable.Range(0, points.Length).Select(i => points[(i + first) % points.Length]));
}
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
{
var values = new List<double>();
if (policy.Kind == RotationPolicyKind.Automatic)
{
// All half-turns matter for asymmetric parts, unlike envelope-only packing.
for (var i = 0; i < 24; i++) values.Add(i * M.PI / 12);
foreach (var line in profile.Perimeter.Entities.OfType<Line>().OrderByDescending(l => l.Length).Take(8))
{
var angle = -M.Atan2(line.EndPoint.Y - line.StartPoint.Y, line.EndPoint.X - line.StartPoint.X);
for (var i = 0; i < 4; i++) values.Add(angle + i * M.PI / 2);
}
}
else
{
var last = policy.Kind == RotationPolicyKind.Fixed ? 0 : M.Floor((policy.End - policy.Start) / policy.Step);
if (!double.IsFinite(last)) last = 720;
var samples = (int)M.Min(720, last);
for (var i = 0; i <= samples; i++)
{
var k = samples == 0 ? 0 : M.Floor(last * ((double)i / samples));
var angle = policy.Start + k * policy.Step;
if (!double.IsFinite(angle) || !policy.Allows(angle)) continue;
values.Add(angle);
if (policy.Allow180Equivalent) values.Add(angle + M.PI);
}
}
var seen = new HashSet<long>();
foreach (var value in values)
{
var angle = value % (2 * M.PI);
if (angle < 0) angle += 2 * M.PI;
if (policy.Allows(angle) && seen.Add((long)M.Round(angle * 1e9))) yield return angle;
}
}
}
-125
View File
@@ -1,125 +0,0 @@
# OpenNest.Engine.Astra
An independent, deterministic .NET 8 CNC nesting plugin. Its public parameterless
`AstraNestingEngine` implements `INestingEngine`. The project remains outside `OpenNest.sln`.
## Placement algorithm
Astra searches configuration space: for each stationary/moving orientation pair, a no-fit
polygon describes the translations that would overlap. Subtracting these regions from the
sheet's usable translation rectangle exposes contact positions where another part can fit.
This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles,
concave interlocking, and insertion into straight-edged and curved holes.
1. Validate immutable job input. Reconstruct owned analytic entities with `DrawingJobMapper`
and `ConvertProgram`. Closed contours define material; internal open marks do not become
holes. Preserve the snapshot's origin when converting normalized placements back to poses.
2. Prepare rotated outlines and material regions with holes, using conservative curve flattening.
Automatic angles combine 15-degree samples over a full turn with orientations aligned to the
longest straight edges. Symmetric duplicates are removed. Prefer up to 16 orientations whose
envelope area is within 8% of the minimum; retain additional orientations when needed to fit
a candidate stock. Fixed and bounded rotation policies remain enforced. Bounded sweeps use
up to 721 integer step indices, including permitted half-turn equivalents.
3. Process high-priority parts first, then parts fitting fewer available stock types, then larger
envelopes. Larger frames precede inserts. Search every retained orientation for each instance.
4. Build cached Minkowski/no-fit regions. Convex pairs use Core's linear convex NFP primitive;
concave pairs use Clipper's integer Minkowski sum. Arc-heavy concave contact outlines use
a coarser mesh with both approximation bounds added to clearance; fine material geometry
still checks every candidate. Positive outer boundaries are filled conservatively.
Axis-aligned rectangles have a four-vertex contact shortcut.
5. Maintain each orientation's available translation region incrementally as parts are added.
Search its boundary vertices, exact-fit contacts and hole anchors. Reject points inside solid
no-fit regions before expensive checks. Check surviving candidates against actual material
regions with holes and spacing offsets. Zero-clearance contacts also pass the shared triangulated
collision check, with tiny position adjustments when rounding makes an exact contact unsafe.
Hole contacts use the same check in the final sheet coordinate frame, with bounded caching.
Two directional objectives try bottom-up and left-to-right growth using the same contact algorithm.
6. Search stock plans with a beam of up to three states. Rank by observed delivery cost and
remaining material, while preserving a state with high placed area. This avoids starving
large-sheet plans in favor of cheap but inefficient small-sheet prefixes. A genuine material
area lower bound prunes plans only once a complete cheaper plan exists. After 24 evaluated
trials only one directional objective is used; after 64, beam width reduces to two.
Work counts, not elapsed time or randomness, control search breadth.
7. Select a complete plan with lowest purchased area, breaking equal-cost ties by sheet count.
If no complete plan is found, maximize fulfilled counts by priority, then minimize cost.
Emit committed-sheet progress, contiguous per-part instance indices, inventory, fulfillment
and the contract's job-level stop reason. Cancellation throws without returning a partial job.
The engine never invokes another engine, registry, job runner, whole-plate nester or filler.
All order, stock, orientation, placement, search and stopping decisions belong to Astra.
Core geometry and Clipper are primitives, not alternative nesters. A shared Core collision fix
corrects curved-hole validation; the placement algorithm remains entirely in Astra.
## Precision and safety
Analytic rotated bounds govern sheet containment. Material curves are conservatively flattened
at 0.001 job units. Positive configuration-space spacing includes 0.0003 extra units for non-rectangular
straight outlines; curved outlines reserve 0.003 extra units even at zero spacing, accounting for offset/chord error and the
benchmark validator's four-decimal grid. Axis-aligned rectangle contacts preserve exact requested
spacing. Actual material intersection checks backstop candidate construction. Both straight-edged
and curved holes are available for insertion. The shared collision routine now subtracts hole
triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
curved-hole false positive. See the benchmark report for regression results.
Concave contact outlines exceeding 64 vertices use a chord tolerance of the greater of 0.002
units or 0.2% of the smaller envelope dimension. The pair's two tolerances are added to the
NFP offset. This reduces Minkowski input size without coarsening the final material checks.
The broad phase is deliberately conservative. It can miss a valid close fit; output validation
is exercised separately through the benchmark's materialized geometry validator in tests.
## Structure
- `AstraNestingEngine.cs`: bounded stock-plan search, accounting, progress and result construction.
- `PreparedGeometry.cs`: snapshots, allowed orientations, symmetry reduction and material regions.
- `ContactGeometry.cs`: cached no-fit polygons and rectangle specialization.
- `ContactPlacer.cs`: incremental available regions, contact/inside-hole search and collision checks.
- `tests/`: xUnit tests plus a linked copy of the existing benchmark validator source.
- `benchmarks/`: standalone synthetic benchmark driver, reproducible repository-DXF manifests,
baseline/current CSV results and comparison notes. It is not compiled into the plugin.
`OpenNest.Engine.Astra.csproj` references Core explicitly and inherits Engine/net8.0 settings from
`../Directory.Build.props`. Test and benchmark sources are excluded from the plugin assembly.
## Build, test and deploy
```bash
dotnet build Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj -c Release
dotnet test Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj -c Release
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
cp Engines/OpenNest.Engine.Astra/bin/Release/net8.0/OpenNest.Engine.Astra.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll Engines/OpenNest.Engine.Astra/benchmarks/dxf --engines AstraNestingEngine --parallel 1
```
The host supplies Core, Engine and their dependencies. Plugin discovery uses the CLR type name
`AstraNestingEngine`; no registry call exists in the plugin.
Rebuild the host with this checkout's `OpenNest.Core` as well: replacing only the plugin DLL
does not update the shared curved-hole collision fix.
## Limitations
This is bounded heuristic search, not a proof of minimum sheet cost or infeasibility. Early part
order is not backtracked within a sheet, already placed parts are not moved, and available
orientations are sampled/pruned. Hole search uses anchor positions, not a complete inner-fit
polygon solver. Small usable regions inside complex cutouts may be missed. The benchmark report
includes an isolated host-validator reproducer and its corrected outcomes.
Filling NFP interior voids can exclude unusual interlocking configurations.
Salvage-credit options and `PlacementStrategy` do not change Astra's objective; `MaxPlates` is
respected. Stock dimensions, all edge spacings, quadrants, priorities and rotation policies are
honored. No real `.nest` fixtures were available in this workspace.
Complex concave outlines, dense bounded sweeps, many part types or very large quantities can
be expensive. NFP and trial cache entry counts are bounded, but individual geometry can be large.
Cancellation is checked throughout search and between geometry operations; shared validation and
individual Clipper calls are not interruptible. The v2 search costs more CPU than the original
bounding-rectangle baseline. See `benchmarks/README.md` for measured tradeoffs.
## Current validation
Release build succeeded with .NET SDK 8.0.425 on Linux. All 27 xUnit cases passed, including
independent benchmark validation of materialized results, exact positive/zero clearance,
non-cardinal rotations, hole insertion, automatic diagonal-only stock fits, all quadrants,
curves, incremental geometry, determinism, inventory, cancellation and stock-plan regressions.
All 34 synthetic/generated benchmark cases and all four repository-DXF cases were valid and complete.
Existing nullable warnings originate from the benchmark validator linked into the test project.
@@ -1,89 +0,0 @@
using OpenNest;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Shapes;
using CncProgram = OpenNest.CNC.Program;
using M = System.Math;
namespace OpenNest.Engine.Astra.Benchmarks;
internal static class GeneratedCases
{
internal static IEnumerable<(string Name, NestJob Job)> Create()
{
var library = new ShapeDefinition[] {
new RoundedRectangleShape { Length = 11, Width = 5, Radius = 1.8 },
new TShape { Width = 10, Height = 9, StemWidth = 2, BarHeight = 2 },
new TrapezoidShape { BottomWidth = 10, TopWidth = 3, Height = 6 },
new NgonShape { Sides = 5, Width = 6 },
new NgonShape { Sides = 6, Width = 6 },
new RingShape { OuterDiameter = 10, InnerDiameter = 7 },
new PipeFlangeShape { OD = 7.5, HoleDiameter = 0.875, HolePatternDiameter = 5.5,
HoleCount = 8, PipeSize = "2", PipeClearance = 0.0625 }
};
for (var i = 0; i < library.Length; i++)
yield return ($"generated-library-{i}-{library[i].Name}", Job(new[] {
Part("main", library[i].GetDrawing().Program, i == 6 ? 12 : 24)
}, i));
yield return ("generated-ring-inserts", Job(new[] {
Part("ring", library[5].GetDrawing().Program, 8),
Part("insert", new CircleShape { Diameter = 6 }.GetDrawing().Program, 8)
}, 1));
var curvedC = new CncProgram();
curvedC.MoveTo(6, 0); curvedC.ArcTo(0, -6, 0, 0, RotationType.CCW);
curvedC.LineTo(0, -4); curvedC.ArcTo(4, 0, 0, 0, RotationType.CW); curvedC.LineTo(6, 0);
yield return ("generated-curved-C", Job(new[] { Part("C", curvedC, 16) }, 2));
yield return ("generated-narrow-U", Job(new[] { Part("U", Poly(0, 0, 10, 0, 10, 10,
8.5, 10, 8.5, 1.5, 1.5, 1.5, 1.5, 10, 0, 10), 24) }, 3));
yield return ("generated-stars", Job(new[] { Part("star", Star(7, 6, 2.5), 20) }, 0));
for (var seed = 0; seed < 12; seed++)
{
var random = new Random(19073 + seed);
var parts = new List<NestJobPart>();
for (var p = 0; p < 4; p++)
{
CncProgram program;
if (p == 0) program = new RoundedRectangleShape { Length = 5 + random.NextDouble() * 7,
Width = 3 + random.NextDouble() * 3, Radius = 0.7 }.GetDrawing().Program;
else if (p == 1) program = Star(5 + seed % 3, 3 + random.NextDouble() * 2, 1.5 + random.NextDouble());
else if (p == 2) program = new TrapezoidShape { BottomWidth = 5 + random.NextDouble() * 5,
TopWidth = 2 + random.NextDouble() * 2, Height = 3 + random.NextDouble() * 4 }.GetDrawing().Program;
else program = new NgonShape { Sides = 3 + seed % 5, Width = 3 + random.NextDouble() * 3 }.GetDrawing().Program;
// Nonzero source origins exercise pose reconstruction as well as shape packing.
program.Offset(new Vector(seed * 1.37 - 5, p * 2.13 - 3));
var rotation = p == 2 ? RotationPolicy.Fixed((seed % 4) * M.PI / 7) :
p == 3 ? RotationPolicy.BoundedSweep(-M.PI / 3, M.PI / 2, M.PI / 6, true) : RotationPolicy.Automatic;
parts.Add(Part($"p{p}", program, random.Next(3, 9), rotation));
}
yield return ($"generated-seed-{seed:00}", Job(parts.ToArray(), seed));
}
}
private static NestJob Job(NestJobPart[] parts, int seed) => new(parts, new[] {
new NestPlateStock("small", new Size(23 + seed % 3, 41 + seed % 5), 2,
seed % 4 == 0 ? 0 : 0.1 + seed % 3 * 0.075, new Spacing(0.2, 0.3, 0.4, 0.5), seed % 4 + 1),
new NestPlateStock("large", new Size(47, 83), partSpacing: 0.2,
edgeSpacing: new Spacing(0.3, 0.2, 0.5, 0.4), quadrant: seed % 4 + 1)
});
private static NestJobPart Part(string name, CncProgram p, int quantity, RotationPolicy rotation = null) =>
new(name, PartGeometrySnapshot.FromProgram(p), quantity, rotation: rotation);
private static CncProgram Star(int arms, double outer, double inner)
{
var coordinates = Enumerable.Range(0, arms * 2).SelectMany(i => {
var radius = i % 2 == 0 ? outer : inner;
var angle = i * M.PI / arms;
return new[] { radius * M.Cos(angle), radius * M.Sin(angle) };
}).ToArray();
return Poly(coordinates);
}
private static CncProgram Poly(params double[] points)
{
var p = new CncProgram(); p.MoveTo(points[0], points[1]);
for (var i = 2; i < points.Length; i += 2) p.LineTo(points[i], points[i + 1]);
p.LineTo(points[0], points[1]); return p;
}
}
@@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../OpenNest.Engine.Astra.csproj" />
<Compile Include="../../../OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
</ItemGroup>
</Project>
@@ -1,94 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.Loader;
using OpenNest;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
using OpenNest.Benchmark;
using CncProgram = OpenNest.CNC.Program;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
if (args.Contains("--diagnose-ring"))
{
var ringPart = new NestJobPart("ring", PartGeometrySnapshot.FromProgram(
new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing().Program), 1);
var insertPart = new NestJobPart("insert", PartGeometrySnapshot.FromProgram(
new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing().Program), 1);
foreach (var quadrant in new[] { 1, 2 })
foreach (var offset in new[] { 0.0, 0.0003, 0.05, 0.1 })
{
var s = new NestPlateStock("s", new Size(24, 42), 1, 0.175,
new Spacing(0.2, 0.3, 0.4, 0.5), quadrant);
var j = new NestJob(new[] { ringPart, insertPart }, new[] { s });
var x = (quadrant == 1 ? 0 : -42) + s.EdgeSpacing.Left + 5;
var y = s.EdgeSpacing.Bottom + 5;
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, s, new[] { new NestJobPlacement("ring", 0, x, 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 StockUsage("s", 1, 0) });
var materialized = NestResultMaterializer.Materialize(j, result);
var check = NestValidator.Validate(materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
j.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
Console.WriteLine($"q={quadrant} offset={offset} valid={check.Valid}: {string.Join(';', check.Violations)}");
}
return;
}
var assembly = args.Length > 0 && args[0].EndsWith(".dll")
? new AssemblyLoadContext("benchmark-plugin", isCollectible: true).LoadFromAssemblyPath(Path.GetFullPath(args[0])) : Assembly.Load("OpenNest.Engine.Astra");
var engine = (INestingEngine)Activator.CreateInstance(assembly.GetType("OpenNest.Engine.Astra.AstraNestingEngine")!);
var cases = new List<(string Name, NestJob Job)>();
var standard = new[] { new NestPlateStock("small", new Size(24, 48), partSpacing: 0.15),
new NestPlateStock("large", new Size(48, 96), partSpacing: 0.15) };
NestJobPart Part(string name, CncProgram p, int count, RotationPolicy rotation = null) =>
new(name, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation);
CncProgram Polygon(params double[] xy)
{
var p = new CncProgram(); p.MoveTo(xy[0], xy[1]);
for (var i = 2; i < xy.Length; i += 2) p.LineTo(xy[i], xy[i + 1]);
p.LineTo(xy[0], xy[1]); return p;
}
CncProgram Rect(double w, double h) => Polygon(0, 0, w, 0, w, h, 0, h);
CncProgram Circle(double r) { var p = new CncProgram(); p.MoveTo(r, 0); p.ArcTo(r, 0, 0, 0, RotationType.CCW); return p; }
void Add(string name, NestJobPart[] parts, NestPlateStock[] stocks = null, NestJobOptions options = null) =>
cases.Add((name, new NestJob(parts, stocks ?? standard, options)));
Add("triangles", new[] { Part("triangle", Polygon(0, 0, 10, 0, 0, 10), 60) });
Add("circles", new[] { Part("circle", Circle(2.5), 90) });
Add("circles-dense", new[] { Part("circle", Circle(2.5), 80) });
Add("concave-L", new[] { Part("L", Polygon(0, 0, 8, 0, 8, 2, 2, 2, 2, 8, 0, 8), 60) });
Add("mixed", new[] { Part("rect", Rect(9, 4), 30), Part("triangle", Polygon(0, 0, 8, 0, 3, 6), 25),
Part("circle", Circle(2), 30), Part("L", Polygon(0, 0, 7, 0, 7, 2, 2, 2, 2, 6, 0, 6), 20) });
var ring = Rect(10, 10); ring.MoveTo(1, 1); ring.LineTo(1, 9); ring.LineTo(9, 9); ring.LineTo(9, 1); ring.LineTo(1, 1);
Add("holes", new[] { Part("frame", ring, 8), Part("insert", Rect(7, 7), 8) });
Add("rectangles", Enumerable.Range(0, 8).Select(i => Part($"r{i}", Rect(2 + i, 3 + i % 3), 12)).ToArray());
Add("grain", new[] { Part("fixed", Rect(13, 3), 20, RotationPolicy.Fixed(System.Math.PI / 6)),
Part("sweep", Rect(7, 2), 40, RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4)) });
Add("scarce-stock", new[] { Part("small", Rect(4, 4), 8), Part("large", Rect(10, 10), 1) },
new[] { new NestPlateStock("scarce", new Size(10, 10), 1), new NestPlateStock("small-only", new Size(4, 8)) });
Add("tail", new[] { Part("rect", Rect(6, 4), 17) }, new[] {
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1), new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
Add("plate-cap", new[] { Part("r", Rect(5, 5), 10) }, new[] {
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20)) }, new NestJobOptions(maxPlates: 1));
cases.AddRange(OpenNest.Engine.Astra.Benchmarks.GeneratedCases.Create());
Console.WriteLine("case,valid,placed,requested,sheets,area,milliseconds");
foreach (var (name, job) in cases)
{
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
var sw = Stopwatch.StartNew();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
try
{
var result = engine.Solve(job, token: cts.Token); sw.Stop();
var nest = NestResultMaterializer.Materialize(job, result);
var validation = NestValidator.Validate(nest.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
job.Parts.ToDictionary(p => nest.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
if (!validation.Valid) Environment.ExitCode = 1;
Console.WriteLine($"{name},{validation.Valid},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width)},{sw.ElapsedMilliseconds}");
foreach (var violation in validation.Violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
}
catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); }
}
@@ -1,182 +0,0 @@
# Astra development benchmark report
Measured locally on Linux with .NET SDK 8.0.425, Release builds, 2026-09-23.
The shared-validator fix was verified on 2026-09-24; its results are recorded separately below.
The baseline is Astra's original independent guillotine/bounding-rectangle implementation,
archived before the contact-search rewrite. These are not measurements against Opus or a
claim of performance on an unseen competition dataset.
## Synthetic cases
Both versions were run on exactly the same programmatically generated geometry and stock.
The driver validates materialized output with `OpenNest.Benchmark.NestValidator`, including
quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only,
exclude external validation, and are single-run observations rather than stable distributions.
Every contact result is valid and complete. The baseline is valid but incomplete on `plate-cap`.
| Case | Baseline area | Contact area | Change | Contact time (ms) |
|---|---:|---:|---:|---:|
| triangles | 8064 | 4608 | -42.9% | 3354 |
| circles | 3456 | 3456 | 0.0% | 1021 |
| circles-dense | 3456 | 2304 | -33.3% | 742 |
| concave-L | 5760 | 3456 | -40.0% | 1055 |
| mixed | 4608 | 3456 | -25.0% | 1990 |
| holes | 2304 | 1152 | -50.0% | 306 |
| rectangles | 3456 | 3456 | 0.0% | 261 |
| grain | 4608 | 2304 | -50.0% | 527 |
| scarce-stock | 228 | 228 | 0.0% | 1 |
| tail | 600 | 600 | 0.0% | 4 |
| plate-cap | 100 | 400 | 4/10 → 10/10 placed | 4 |
Excluding `plate-cap`, where baseline completion differs, purchased area fell from 36540
to 25020: **31.5% less area** across these ten cases. The original solver
usually took 0–30 ms; contact search takes approximately 1 ms to 3.4 s on this set. Packing
quality improved at a substantial CPU cost. No speedup over the original baseline is claimed.
## Extended generated cases
`GeneratedCases.cs` adds 23 jobs: rounded rectangles, T-shapes, trapezoids, pentagons,
hexagons, rings, pipe flanges, ring/insert mixtures, curved C-shapes, narrow U-shapes,
stars, and 12 seeded mixed jobs. These exercise all four quadrants, asymmetric edge margins,
finite small-sheet inventory, translated source origins, zero/positive spacing, fixed
non-cardinal rotations and bounded sweeps. The library supplies most shapes; the C, U and
star contours are generated directly. Seeded cases use seeds 19073 through 19084.
Both versions place every requested part in all 23 jobs with valid output. Seven cases use
less purchased area; the other sixteen match the baseline. Aggregate area drops from
58259 to 44353, **23.9% less area**. The changed cases are:
| Generated case | Baseline area | Contact area | Reduction |
|---|---:|---:|---:|
| T-shapes | 5917 | 2016 | 65.9% |
| Hexagons | 2160 | 1080 | 50.0% |
| Pipe flanges | 1932 | 966 | 50.0% |
| Curved C-shapes | 6051 | 2150 | 64.5% |
| Narrow U-shapes | 5925 | 3901 | 34.2% |
| Seed 19083 | 1968 | 984 | 50.0% |
| Seed 19084 | 2100 | 1050 | 50.0% |
The initial fine-mesh curved-C search exceeded the driver's 90-second cancellation budget
(an in-flight Minkowski operation delayed cancellation to 110 seconds). Separately coarsening
its contact outline, padding both approximation errors, and retaining fine safety geometry
reduced that case to approximately 2.3–2.5 seconds. No wall-clock cutoff was added to the engine.
The final regression pass validates all 34 generated/synthetic jobs and all four DXF jobs;
27 independent xUnit cases also pass. Raw results are in `results/`.
## Curved-hole validator fix (2026-09-24)
The generated ring/insert job exposed a shared-validator false positive. A ring with inner
radius 3.5 containing a concentric radius-3 disk has 0.5 units of clearance, yet the host's
triangulated collision check can report a violation with required spacing 0.175. The outcome
also changes with translations. Checking every candidate against that routine made a small
ring job take approximately 80–90 seconds.
Astra initially reserved curved cutouts as solid during placement, preserving the original
drawing in output. This workaround finished the ring/insert job in about 50 ms at the baseline
sheet cost. It has now been removed following a fix in `OpenNest.Core/Geometry/Collision.cs`.
Hole subtraction previously clipped each fragment independently against every triangle edge,
duplicating surviving area. It also classified points with an epsilon-shifted boundary but
intersected against the unshifted line, which could extrapolate outside the source segment.
The corrected routine emits disjoint outside fragments and carries the inside remainder to
the next edge. Classification and interpolation use the same signed cross products. Exact
closing vertices and local-coordinate area checks avoid additional small-fragment errors.
This remains the shared hand-written collision algorithm; Clipper is only an independent
oracle in the new tests, not a replacement per-pair validator.
The eight original translated reproductions all pass. Regression coverage also rejects real
spacing violations, checks both operand orders and windings, exercises all four quadrants,
and compares overlap areas against Clipper on 80 seeded pairs with multiple/concave holes.
The main suite passes 1,096 tests (12 font-fixture skips), Engine passes 170, and Astra passes
27. Astra's curved-hole tests now require a ring and insert to share stock whose usable area
fits only the ring, proving that insertion is enabled.
All 34 synthetic/generated jobs remain valid and complete with unchanged sheet-area costs.
The ring/insert job with hole search enabled takes about 4.8 seconds in this run and still
uses two small sheets. This fix improves validity and enables insertion; it does not improve
that job's stock plan. Results are in `results/validator-fixed-synthetic-generated.csv`.
All four repository-DXF jobs also remain valid and complete at unchanged sheet-area costs;
their rerun is recorded in `results/validator-fixed-dxf.csv`.
The isolated reproduction does not invoke any nesting engine:
```bash
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release -- --diagnose-ring
```
`results/ring-validator-reproducer.txt` retains the original failures;
`results/ring-validator-fixed.txt` records the corrected outcomes. Rebuild the host's Core
dependency when deploying. The benchmark validator's spacing rules and source are unchanged.
## Repository DXFs
The four manifests under `dxf/` use PT45, PT23 and PT11 repository drawings. Every result from
both versions was valid and complete. Runs used `--parallel 1`.
| Manifest | Baseline area | Contact area | Change |
|---|---:|---:|---:|
| locked.manifest | 115200 | 115200 | 0.0% |
| mixed.manifest | 144000 | 115200 | -20.0% |
| original.manifest | 115200 | 115200 | 0.0% |
| volume.manifest | 374400 | 374400 | 0.0% |
The mixed three-drawing job improves 20%; the other three retain baseline sheet-area cost.
The initial contact version regressed on `volume`; preserving a high-progress beam state and
ranking with observed per-part delivery cost removed that regression. Final results purchase
720000 area units versus 748800, a 3.8% reduction across the four manifests. Fewer physical
sheets sometimes have the same purchased area; those are not counted as area savings.
An exploratory run of the original manifest against StockLadder and Default found StockLadder
valid/complete at the same 115200 area cost; Default's result was flagged for spacing. That
single case does not establish general superiority. There is no Opus result available here.
## Local geometry optimizations
Apart from the shared Core collision fix described above, these optimizations are in Astra.
Astra caches NFPs and incrementally
subtracts each newly placed part from available translation regions, avoiding repeated unions
of all previous obstacles. A four-vertex rectangle configuration-space specialization avoids
round-offset polygons and polygon collision work for exact axis-aligned rectangle contacts.
During development, the 96-rectangle case dropped from roughly 1.5 s to 0.24 s after this
specialization. This is an end-to-end observation, not an isolated component microbenchmark.
Precision regression tests cover exact clearances and rotated zero-spacing contacts. In the
latter case, the host's four-decimal polygon rounding and triangulated collision test can
reject a contact accepted by Clipper at six decimals. Astra now retains the original rotated
frame for that validation, checks zero-clearance contacts with Core's collision primitive,
and tries tiny nearby translations when exact contact is unsafe.
## Reproduce
Run from the repository root:
```bash
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release
```
Run only the extended generated suite with `-- current generated`; the first positional
argument is either a previous plugin DLL or a label for the current build. Invalid layouts
and crashes make the driver exit with a nonzero status. Incompleteness is reported separately.
The standalone driver also accepts a prior plugin DLL and optional case-name filter:
```bash
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release -- /path/to/previous/OpenNest.Engine.Astra.dll triangles
```
An isolated assembly load context prevents .NET from silently substituting the currently
built plugin when comparing another version with the same assembly name. Historical baseline
CSV files are included; the old binary is not committed. The driver's 90-second cancellation
budget is a benchmark safeguard and is not an elapsed-time stopping rule inside the engine.
For real DXFs, build and deploy the plugin as described in the parent README, then:
```bash
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll Engines/OpenNest.Engine.Astra/benchmarks/dxf --engines AstraNestingEngine --parallel 1 --csv /tmp/astra-dxf.csv
```
The CSV files under `results/` retain the measured results. Tests run independently:
```bash
dotnet test Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj -c Release
```
@@ -1,20 +0,0 @@
{
"sheetSizes": [
"120x240",
"240x480"
],
"spacing": 0.25,
"edgeSpacing": 0.5,
"parts": [
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
"quantity": 8,
"allowRotation": false
},
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
"quantity": 4,
"allowRotation": false
}
]
}
@@ -1,25 +0,0 @@
{
"sheetSizes": [
"120x240",
"240x480"
],
"spacing": 0.25,
"edgeSpacing": 0.5,
"parts": [
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
"quantity": 5,
"allowRotation": true
},
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
"quantity": 4,
"allowRotation": true
},
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT11.dxf",
"quantity": 6,
"allowRotation": true
}
]
}
@@ -1,20 +0,0 @@
{
"sheetSizes": [
"120x240",
"240x480"
],
"spacing": 0.25,
"edgeSpacing": 0.5,
"parts": [
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
"quantity": 8,
"allowRotation": true
},
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
"quantity": 4,
"allowRotation": true
}
]
}
@@ -1,20 +0,0 @@
{
"sheetSizes": [
"120x240",
"240x480"
],
"spacing": 0.25,
"edgeSpacing": 0.5,
"parts": [
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
"quantity": 32,
"allowRotation": true
},
{
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
"quantity": 16,
"allowRotation": true
}
]
}
@@ -1,5 +0,0 @@
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,83,
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.5983,0.5983,144000.00,144000.00,144000.00,5,120 x 240×5,298,
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,4,120 x 240×4,21,
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,51,
1 Job Engine Valid Crashed FullyPlaced PartsPlaced PartsRequested Utilization NetUtilization PlateArea NetSheetArea Cost PlatesUsed SizeBreakdown ElapsedMs Notes
2 locked.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 1 240 x 480×1 83
3 mixed.manifest AstraNestingEngine True False True 15 15 0.5983 0.5983 144000.00 144000.00 144000.00 5 120 x 240×5 298
4 original.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 4 120 x 240×4 21
5 volume.manifest AstraNestingEngine True False True 48 48 0.7977 0.7977 374400.00 374400.00 374400.00 4 240 x 480×3; 120 x 240×1 51
@@ -1,24 +0,0 @@
case,valid,placed,requested,sheets,area,milliseconds
generated-library-0-RoundedRectangle,True,24,24,2,1886,24
generated-library-1-T,True,24,24,3,5917,0
generated-library-2-Trapezoid,True,24,24,2,2150,0
generated-library-3-Ngon,True,24,24,2,2024,0
generated-library-4-Ngon,True,24,24,2,2160,0
generated-library-5-Ring,True,24,24,1,3901,3
generated-library-6-PipeFlange,True,12,12,2,1932,19
generated-ring-inserts,True,16,16,2,2016,2
generated-curved-C,True,16,16,3,6051,0
generated-narrow-U,True,24,24,3,5925,0
generated-stars,True,20,20,1,3901,0
generated-seed-00,True,19,19,2,1886,1
generated-seed-01,True,22,22,2,2016,1
generated-seed-02,True,21,21,2,2150,1
generated-seed-03,True,20,20,1,1012,1
generated-seed-04,True,24,24,1,1080,1
generated-seed-05,True,22,22,2,2050,1
generated-seed-06,True,27,27,2,1932,1
generated-seed-07,True,19,19,1,1032,1
generated-seed-08,True,17,17,1,1100,1
generated-seed-09,True,22,22,2,2070,1
generated-seed-10,True,20,20,2,1968,1
generated-seed-11,True,19,19,2,2100,1
1 case valid placed requested sheets area milliseconds
2 generated-library-0-RoundedRectangle True 24 24 2 1886 24
3 generated-library-1-T True 24 24 3 5917 0
4 generated-library-2-Trapezoid True 24 24 2 2150 0
5 generated-library-3-Ngon True 24 24 2 2024 0
6 generated-library-4-Ngon True 24 24 2 2160 0
7 generated-library-5-Ring True 24 24 1 3901 3
8 generated-library-6-PipeFlange True 12 12 2 1932 19
9 generated-ring-inserts True 16 16 2 2016 2
10 generated-curved-C True 16 16 3 6051 0
11 generated-narrow-U True 24 24 3 5925 0
12 generated-stars True 20 20 1 3901 0
13 generated-seed-00 True 19 19 2 1886 1
14 generated-seed-01 True 22 22 2 2016 1
15 generated-seed-02 True 21 21 2 2150 1
16 generated-seed-03 True 20 20 1 1012 1
17 generated-seed-04 True 24 24 1 1080 1
18 generated-seed-05 True 22 22 2 2050 1
19 generated-seed-06 True 27 27 2 1932 1
20 generated-seed-07 True 19 19 1 1032 1
21 generated-seed-08 True 17 17 1 1100 1
22 generated-seed-09 True 22 22 2 2070 1
23 generated-seed-10 True 20 20 2 1968 1
24 generated-seed-11 True 19 19 2 2100 1
@@ -1,12 +0,0 @@
case,valid,placed,requested,sheets,area,milliseconds
triangles,True,60,60,4,8064,30
circles,True,90,90,3,3456,4
circles-dense,True,80,80,3,3456,0
concave-L,True,60,60,2,5760,0
mixed,True,105,105,4,4608,1
holes,True,16,16,2,2304,2
rectangles,True,96,96,3,3456,1
grain,True,60,60,4,4608,0
scarce-stock,True,9,9,5,228,0
tail,True,17,17,1,600,0
plate-cap,True,4,10,1,100,0
1 case valid placed requested sheets area milliseconds
2 triangles True 60 60 4 8064 30
3 circles True 90 90 3 3456 4
4 circles-dense True 80 80 3 3456 0
5 concave-L True 60 60 2 5760 0
6 mixed True 105 105 4 4608 1
7 holes True 16 16 2 2304 2
8 rectangles True 96 96 3 3456 1
9 grain True 60 60 4 4608 0
10 scarce-stock True 9 9 5 228 0
11 tail True 17 17 1 600 0
12 plate-cap True 4 10 1 100 0
@@ -1,5 +0,0 @@
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,259,
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,1724,
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,133,
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,389,
1 Job Engine Valid Crashed FullyPlaced PartsPlaced PartsRequested Utilization NetUtilization PlateArea NetSheetArea Cost PlatesUsed SizeBreakdown ElapsedMs Notes
2 locked.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 1 240 x 480×1 259
3 mixed.manifest AstraNestingEngine True False True 15 15 0.7479 0.7479 115200.00 115200.00 115200.00 1 240 x 480×1 1724
4 original.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 1 240 x 480×1 133
5 volume.manifest AstraNestingEngine True False True 48 48 0.7977 0.7977 374400.00 374400.00 374400.00 4 240 x 480×3; 120 x 240×1 389
@@ -1,24 +0,0 @@
case,valid,placed,requested,sheets,area,milliseconds
generated-library-0-RoundedRectangle,True,24,24,2,1886,175
generated-library-1-T,True,24,24,2,2016,175
generated-library-2-Trapezoid,True,24,24,2,2150,120
generated-library-3-Ngon,True,24,24,2,2024,536
generated-library-4-Ngon,True,24,24,1,1080,93
generated-library-5-Ring,True,24,24,1,3901,85
generated-library-6-PipeFlange,True,12,12,1,966,46
generated-ring-inserts,True,16,16,2,2016,48
generated-curved-C,True,16,16,2,2150,1929
generated-narrow-U,True,24,24,1,3901,136
generated-stars,True,20,20,1,3901,3118
generated-seed-00,True,19,19,2,1886,578
generated-seed-01,True,22,22,2,2016,403
generated-seed-02,True,21,21,2,2150,1447
generated-seed-03,True,20,20,1,1012,356
generated-seed-04,True,24,24,1,1080,600
generated-seed-05,True,22,22,2,2050,1467
generated-seed-06,True,27,27,2,1932,1251
generated-seed-07,True,19,19,1,1032,724
generated-seed-08,True,17,17,1,1100,980
generated-seed-09,True,22,22,2,2070,930
generated-seed-10,True,20,20,1,984,460
generated-seed-11,True,19,19,1,1050,1151
1 case valid placed requested sheets area milliseconds
2 generated-library-0-RoundedRectangle True 24 24 2 1886 175
3 generated-library-1-T True 24 24 2 2016 175
4 generated-library-2-Trapezoid True 24 24 2 2150 120
5 generated-library-3-Ngon True 24 24 2 2024 536
6 generated-library-4-Ngon True 24 24 1 1080 93
7 generated-library-5-Ring True 24 24 1 3901 85
8 generated-library-6-PipeFlange True 12 12 1 966 46
9 generated-ring-inserts True 16 16 2 2016 48
10 generated-curved-C True 16 16 2 2150 1929
11 generated-narrow-U True 24 24 1 3901 136
12 generated-stars True 20 20 1 3901 3118
13 generated-seed-00 True 19 19 2 1886 578
14 generated-seed-01 True 22 22 2 2016 403
15 generated-seed-02 True 21 21 2 2150 1447
16 generated-seed-03 True 20 20 1 1012 356
17 generated-seed-04 True 24 24 1 1080 600
18 generated-seed-05 True 22 22 2 2050 1467
19 generated-seed-06 True 27 27 2 1932 1251
20 generated-seed-07 True 19 19 1 1032 724
21 generated-seed-08 True 17 17 1 1100 980
22 generated-seed-09 True 22 22 2 2070 930
23 generated-seed-10 True 20 20 1 984 460
24 generated-seed-11 True 19 19 1 1050 1151
@@ -1,12 +0,0 @@
case,valid,placed,requested,sheets,area,milliseconds
triangles,True,60,60,1,4608,3354
circles,True,90,90,3,3456,1021
circles-dense,True,80,80,2,2304,742
concave-L,True,60,60,3,3456,1055
mixed,True,105,105,3,3456,1990
holes,True,16,16,1,1152,306
rectangles,True,96,96,3,3456,261
grain,True,60,60,2,2304,527
scarce-stock,True,9,9,5,228,1
tail,True,17,17,1,600,4
plate-cap,True,10,10,1,400,4
1 case valid placed requested sheets area milliseconds
2 triangles True 60 60 1 4608 3354
3 circles True 90 90 3 3456 1021
4 circles-dense True 80 80 2 2304 742
5 concave-L True 60 60 3 3456 1055
6 mixed True 105 105 3 3456 1990
7 holes True 16 16 1 1152 306
8 rectangles True 96 96 3 3456 261
9 grain True 60 60 2 2304 527
10 scarce-stock True 9 9 5 228 1
11 tail True 17 17 1 600 4
12 plate-cap True 10 10 1 400 4
@@ -1,8 +0,0 @@
q=1 offset=0 valid=True:
q=1 offset=0.0003 valid=True:
q=1 offset=0.05 valid=True:
q=1 offset=0.1 valid=True:
q=2 offset=0 valid=True:
q=2 offset=0.0003 valid=True:
q=2 offset=0.05 valid=True:
q=2 offset=0.1 valid=True:
@@ -1,8 +0,0 @@
q=1 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
q=1 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
q=1 offset=0.05 valid=True:
q=1 offset=0.1 valid=True:
q=2 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
q=2 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
q=2 offset=0.05 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
q=2 offset=0.1 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
@@ -1,5 +0,0 @@
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,466,
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,2820,
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,435,
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,2278,
1 Job Engine Valid Crashed FullyPlaced PartsPlaced PartsRequested Utilization NetUtilization PlateArea NetSheetArea Cost PlatesUsed SizeBreakdown ElapsedMs Notes
2 locked.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 1 240 x 480×1 466
3 mixed.manifest AstraNestingEngine True False True 15 15 0.7479 0.7479 115200.00 115200.00 115200.00 1 240 x 480×1 2820
4 original.manifest AstraNestingEngine True False True 12 12 0.6481 0.6481 115200.00 115200.00 115200.00 1 240 x 480×1 435
5 volume.manifest AstraNestingEngine True False True 48 48 0.7977 0.7977 374400.00 374400.00 374400.00 4 240 x 480×3; 120 x 240×1 2278
@@ -1,35 +0,0 @@
case,valid,placed,requested,sheets,area,milliseconds
triangles,True,60,60,1,4608,3344
circles,True,90,90,3,3456,1008
circles-dense,True,80,80,2,2304,748
concave-L,True,60,60,3,3456,1082
mixed,True,105,105,3,3456,1966
holes,True,16,16,1,1152,293
rectangles,True,96,96,3,3456,246
grain,True,60,60,2,2304,513
scarce-stock,True,9,9,5,228,1
tail,True,17,17,1,600,4
plate-cap,True,10,10,1,400,4
generated-library-0-RoundedRectangle,True,24,24,2,1886,170
generated-library-1-T,True,24,24,2,2016,172
generated-library-2-Trapezoid,True,24,24,2,2150,115
generated-library-3-Ngon,True,24,24,2,2024,510
generated-library-4-Ngon,True,24,24,1,1080,89
generated-library-5-Ring,True,24,24,1,3901,918
generated-library-6-PipeFlange,True,12,12,1,966,1467
generated-ring-inserts,True,16,16,2,2016,4755
generated-curved-C,True,16,16,2,2150,1883
generated-narrow-U,True,24,24,1,3901,134
generated-stars,True,20,20,1,3901,3087
generated-seed-00,True,19,19,2,1886,568
generated-seed-01,True,22,22,2,2016,398
generated-seed-02,True,21,21,2,2150,1427
generated-seed-03,True,20,20,1,1012,352
generated-seed-04,True,24,24,1,1080,600
generated-seed-05,True,22,22,2,2050,1447
generated-seed-06,True,27,27,2,1932,1254
generated-seed-07,True,19,19,1,1032,705
generated-seed-08,True,17,17,1,1100,1008
generated-seed-09,True,22,22,2,2070,1025
generated-seed-10,True,20,20,1,984,504
generated-seed-11,True,19,19,1,1050,1353
1 case valid placed requested sheets area milliseconds
2 triangles True 60 60 1 4608 3344
3 circles True 90 90 3 3456 1008
4 circles-dense True 80 80 2 2304 748
5 concave-L True 60 60 3 3456 1082
6 mixed True 105 105 3 3456 1966
7 holes True 16 16 1 1152 293
8 rectangles True 96 96 3 3456 246
9 grain True 60 60 2 2304 513
10 scarce-stock True 9 9 5 228 1
11 tail True 17 17 1 600 4
12 plate-cap True 10 10 1 400 4
13 generated-library-0-RoundedRectangle True 24 24 2 1886 170
14 generated-library-1-T True 24 24 2 2016 172
15 generated-library-2-Trapezoid True 24 24 2 2150 115
16 generated-library-3-Ngon True 24 24 2 2024 510
17 generated-library-4-Ngon True 24 24 1 1080 89
18 generated-library-5-Ring True 24 24 1 3901 918
19 generated-library-6-PipeFlange True 12 12 1 966 1467
20 generated-ring-inserts True 16 16 2 2016 4755
21 generated-curved-C True 16 16 2 2150 1883
22 generated-narrow-U True 24 24 1 3901 134
23 generated-stars True 20 20 1 3901 3087
24 generated-seed-00 True 19 19 2 1886 568
25 generated-seed-01 True 22 22 2 2016 398
26 generated-seed-02 True 21 21 2 2150 1427
27 generated-seed-03 True 20 20 1 1012 352
28 generated-seed-04 True 24 24 1 1080 600
29 generated-seed-05 True 22 22 2 2050 1447
30 generated-seed-06 True 27 27 2 1932 1254
31 generated-seed-07 True 19 19 1 1032 705
32 generated-seed-08 True 17 17 1 1100 1008
33 generated-seed-09 True 22 22 2 2070 1025
34 generated-seed-10 True 20 20 1 984 504
35 generated-seed-11 True 19 19 1 1050 1353
@@ -1,347 +0,0 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Astra.Tests;
public class AstraNestingEngineTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedPartsRespectBoundsSpacingRotationAndIdentity(int quadrant)
{
var job = new NestJob(new[] {
Rectangle("a", 4, 2, 12, RotationPolicy.Fixed(System.Math.PI / 2), 7, -3),
Rectangle("b", 3, 3, 8, RotationPolicy.BoundedSweep(-System.Math.PI / 4, System.Math.PI / 2, System.Math.PI / 4))
}, new[] { new NestPlateStock("stock", new Size(15, 20), 10, 0.25,
new Spacing(1, 2, 3, 1), quadrant) });
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions);
var again = new AstraNestingEngine().Solve(job);
Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements));
Assert.Equal(result.Plates.Select(p => p.StockId), again.Plates.Select(p => p.StockId));
}
[Fact]
public void ChoosesSmallestSheetWhenDemandFitsBoth()
{
var job = new NestJob(new[] { Rectangle("p", 2, 2, 1) }, new[] {
new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal("small", Assert.Single(result.Plates).StockId);
Validate(job, result);
}
[Theory]
[InlineData(1, null, NestJobStopReason.StockExhausted)]
[InlineData(null, 1, NestJobStopReason.PlateLimitReached)]
public void StopsAtInventoryOrPlateLimit(int? quantity, int? limit, NestJobStopReason reason)
{
var job = new NestJob(new[] { Rectangle("p", 2, 2, 3) },
new[] { new NestPlateStock("s", new Size(2, 2), quantity) }, new NestJobOptions(maxPlates: limit));
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(reason, result.StopReason);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
Validate(job, result);
}
[Fact]
public void ImpossibleAndEmptyJobsTerminate()
{
var part = Rectangle("p", 10, 10, 1);
Assert.Equal(NestJobStopReason.NoPlacementFound, new AstraNestingEngine().Solve(
new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(2, 2)) })).StopReason);
Assert.Equal(NestJobStopReason.StockExhausted, new AstraNestingEngine().Solve(
new NestJob(new[] { part }, Array.Empty<NestPlateStock>())).StopReason);
Assert.Equal(NestJobStatus.Complete, new AstraNestingEngine().Solve(
new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>())).Status);
}
[Fact]
public void CircleBoundsAreAnalyticAndIncrementalGeometryWorks()
{
var circle = new Program();
circle.MoveTo(13, 10);
circle.ArcTo(13, 10, 10, 10, RotationType.CCW);
var incremental = new Program(Mode.Incremental);
incremental.MoveTo(-5, -5);
incremental.LineTo(2, 0);
incremental.LineTo(0, 3);
incremental.LineTo(-2, 0);
incremental.LineTo(0, -3);
var job = new NestJob(new[] {
new NestJobPart("circle", PartGeometrySnapshot.FromProgram(circle), 5),
new NestJobPart("incremental", PartGeometrySnapshot.FromProgram(incremental), 5)
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
[Fact]
public void CancellationBeforeAndDuringSolveThrows()
{
var job = new NestJob(new[] { Rectangle("p", 2, 2, 10) },
new[] { new NestPlateStock("s", new Size(10, 10)) });
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => new AstraNestingEngine().Solve(job, token: cts.Token));
using var during = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => new AstraNestingEngine().Solve(job,
new CallbackProgress(_ => during.Cancel()), during.Token));
}
[Fact]
public void PriorityWinsScarceSpaceAndProgressReflectsCommits()
{
var low = Rectangle("low", 2, 2, 1);
var high = new NestJobPart("high", low.Geometry, 1, priority: 9);
var job = new NestJob(new[] { low, high }, new[] { new NestPlateStock("s", new Size(2, 2), 1) });
var updates = new List<NestJobProgress>();
var result = new AstraNestingEngine().Solve(job, new CallbackProgress(updates.Add));
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
Assert.Equal(NestJobStage.PlateCommitted, updates.Last().Stage);
Assert.Equal(1, updates.Last().CommittedParts);
}
[Fact]
public void ConcaveAndHoledPartsRemainValid()
{
var l = new Program();
l.MoveTo(0, 0); l.LineTo(6, 0); l.LineTo(6, 2);
l.LineTo(2, 2); l.LineTo(2, 6); l.LineTo(0, 6); l.LineTo(0, 0);
var holed = DrawingJobMapper.ToProgram(Rectangle("template", 8, 8, 1).Geometry);
holed.MoveTo(2, 2); holed.LineTo(2, 6); holed.LineTo(6, 6);
holed.LineTo(6, 2); holed.LineTo(2, 2);
var job = new NestJob(new[] {
new NestJobPart("concave", PartGeometrySnapshot.FromProgram(l), 7),
new NestJobPart("hole", PartGeometrySnapshot.FromProgram(holed), 3)
}, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
[Fact]
public void SeededMixedRectanglesPassBenchmarkValidator()
{
var random = new Random(7301);
for (var trial = 0; trial < 12; trial++)
{
var parts = Enumerable.Range(0, 6).Select(i => Rectangle($"p{i}",
random.Next(1, 9), random.Next(1, 9), random.Next(1, 6),
i % 2 == 0 ? RotationPolicy.Automatic : RotationPolicy.Fixed(0))).ToArray();
var job = new NestJob(parts, new[] {
new NestPlateStock("small", new Size(15, 20), 1, 0.1),
new NestPlateStock("large", new Size(25, 30), partSpacing: 0.3)
});
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
}
[Fact]
public void ComplementaryTrianglesShareOneEnvelope()
{
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 2) },
new[] { new NestPlateStock("s", new Size(10, 10), 1) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
}
[Fact]
public void PlacesInsertInsideFrameHole()
{
var frame = DrawingJobMapper.ToProgram(Rectangle("template", 10, 10, 1).Geometry);
frame.MoveTo(1, 1); frame.LineTo(1, 9); frame.LineTo(9, 9);
frame.LineTo(9, 1); frame.LineTo(1, 1);
var job = new NestJob(new[] { new NestJobPart("frame", PartGeometrySnapshot.FromProgram(frame), 1),
Rectangle("insert", 7, 7, 1) }, new[] { new NestPlateStock("s", new Size(10, 10), 1, 0.25) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
}
[Fact]
public void PlateLimitSelectsSheetThatCompletesDemand()
{
var job = new NestJob(new[] { Rectangle("p", 5, 5, 10) }, new[] {
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20))
}, new NestJobOptions(maxPlates: 1));
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId);
Validate(job, result);
}
[Fact]
public void AutomaticDiagonalIsRetainedWhenItIsTheOnlyStockFit()
{
var job = new NestJob(new[] { Rectangle("diagonal", 10, 1, 1, RotationPolicy.Automatic) },
new[] { new NestPlateStock("s", new Size(8, 8), 1) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
[Fact]
public void DenseTrianglesRespectPositiveSpacing()
{
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 20) },
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
[Fact]
public void LargeBatchDoesNotLoseEfficientLargeSheetPlans()
{
var job = new NestJob(new[] { Rectangle("p", 6, 4, 100, RotationPolicy.Automatic) }, new[] {
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1),
new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600);
Validate(job, result);
}
[Fact]
public void ExactPositiveSpacingRectangleGridStillFits()
{
var job = new NestJob(new[] { Rectangle("p", 2, 2, 4) },
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
[Theory]
[InlineData(0)]
[InlineData(0.1)]
public void MixedRotatedContoursPassIndependentValidation(double spacing)
{
for (var trial = 0; trial < 5; trial++)
{
var t = new Program(); t.MoveTo(0, 0); t.LineTo(4 + trial, 0);
t.LineTo(1, 3 + trial); t.LineTo(0, 0);
var job = new NestJob(new[] {
new NestJobPart("t", PartGeometrySnapshot.FromProgram(t), 7),
Rectangle("r", 3, 2, 5, RotationPolicy.Fixed(trial * System.Math.PI / 7))
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
}
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void CurvedCutoutsAcceptInsertsWhenOnlyOneRingFitsTheStock(int quadrant)
{
var ring = new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing();
var insert = new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing();
var job = new NestJob(new[] {
new NestJobPart("ring", PartGeometrySnapshot.FromProgram(ring.Program), 1),
new NestJobPart("insert", PartGeometrySnapshot.FromProgram(insert.Program), 1)
}, new[] { new NestPlateStock("s", new Size(10.8, 10.6), 1, partSpacing: 0.175,
edgeSpacing: new Spacing(0.2, 0.3, 0.4, 0.5), quadrant: quadrant) });
var result = new AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
Validate(job, result);
}
[Fact]
public void CurvedConcavityInterlocksWithoutFineMeshMinkowskiExplosion()
{
var c = new Program();
c.MoveTo(6, 0); c.ArcTo(0, -6, 0, 0, RotationType.CCW);
c.LineTo(0, -4); c.ArcTo(4, 0, 0, 0, RotationType.CW); c.LineTo(6, 0);
var job = new NestJob(new[] { new NestJobPart("C", PartGeometrySnapshot.FromProgram(c), 8) },
new[] { new NestPlateStock("s", new Size(25, 43), 1, 0.25,
new Spacing(0.2, 0.3, 0.4, 0.5), 3) });
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var result = new AstraNestingEngine().Solve(job, token: cancellation.Token);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
}
private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress>
{ public void Report(NestJobProgress value) => callback(value); }
private static NestJobPart Rectangle(string id, double w, double h, int count,
RotationPolicy? rotation = null, double x = 0, double y = 0)
{
var p = new Program();
p.MoveTo(x, y); p.LineTo(x + w, y); p.LineTo(x + w, y + h);
p.LineTo(x, y + h); p.LineTo(x, y);
return new(id, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation ?? RotationPolicy.Fixed(0));
}
private static void Validate(NestJob job, NestJobResult result)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
p => (Name: p.Id, Quantity: p.Quantity));
var validation = OpenNest.Benchmark.NestValidator.Validate(
materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), requirements);
OpenNest.Benchmark.NestValidator.ValidateAgainstJob(job, result,
job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
Assert.True(validation.Valid, string.Join("; ", validation.Violations));
foreach (var sheet in result.Plates)
{
var s = sheet.Stock;
var left = (s.Quadrant is 1 or 4 ? 0 : -s.Size.Length) + s.EdgeSpacing.Left;
var bottom = (s.Quadrant is 1 or 2 ? 0 : -s.Size.Width) + s.EdgeSpacing.Bottom;
var right = left + s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right;
var top = bottom + s.Size.Width - s.EdgeSpacing.Bottom - s.EdgeSpacing.Top;
foreach (var pose in sheet.Placements)
{
var part = job.Parts.Single(p => p.Id == pose.PartId);
Assert.True(part.Rotation.Allows(pose.Rotation));
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToArray();
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
Assert.True(b.L >= left - 1e-7 && b.B >= bottom - 1e-7 && b.R <= right + 1e-7 && b.T <= top + 1e-7);
}
}
foreach (var part in job.Parts)
{
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
Assert.Equal(placed.Length, fulfillment.Placed);
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
}
foreach (var usage in result.StockUsage)
{
var stock = job.Plates.Single(s => s.Id == usage.StockId);
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
Assert.True(usage.Remaining is null or >= 0);
}
}
}
@@ -1,16 +0,0 @@
<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" />
<Compile Include="../../../OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
<ProjectReference Include="../OpenNest.Engine.Astra.csproj" />
</ItemGroup>
</Project>
@@ -1,9 +0,0 @@
{
"sheetSizes": ["120x240", "240x480"],
"spacing": 0.25,
"edgeSpacing": 0.5,
"parts": [
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf", "quantity": 8 },
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf", "quantity": 4 }
]
}
@@ -1,272 +0,0 @@
using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Opus55;
/// <summary>Direction the packing front sweeps across the sheet (the free strip is left behind it).</summary>
internal enum PackAxis
{
/// <summary>Front moves in +X; parts settle toward low X, then low Y.</summary>
X,
/// <summary>Front moves in +Y; parts settle toward low Y, then low X.</summary>
Y,
}
internal sealed record Placed(Orientation Orientation, double X, double Y)
{
public double Left => X + Orientation.MinX;
public double Right => X + Orientation.MaxX;
public double Bottom => Y + Orientation.MinY;
public double Top => Y + Orientation.MaxY;
}
internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Parts, double PartArea);
/// <summary>
/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions.
///
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
/// everything already placed. Each placement subtracts one translated NFP from each region,
/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
///
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
/// largest such part at its lowest such point.
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
/// </summary>
internal sealed class FrontierPacker
{
/// <summary>Slack added around the inner-fit rectangle so zero-width fits survive Clipper;
/// chosen points are clamped back, which moves them far less than the clearance margin.</summary>
private const double FitSlack = 2e-4;
private const double Tie = 1e-6;
private readonly IReadOnlyList<PartType> types;
private readonly NoFitCache nfps;
private readonly NestPlateStock stock;
private readonly PackAxis axis;
private readonly double beta;
private readonly Box work;
private readonly WorkCounter counter;
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
{
this.counter = counter;
this.types = types;
this.nfps = nfps;
this.stock = stock;
this.axis = axis;
this.beta = beta;
work = WorkArea(stock);
}
public static Box WorkArea(NestPlateStock stock)
{
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
return new Box(
left + stock.EdgeSpacing.Left,
bottom + stock.EdgeSpacing.Bottom,
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top
);
}
/// <summary>True when the orientation's bounds fit the work area at all (Box.Length is the X extent).</summary>
public static bool Fits(Orientation o, Box work) =>
o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9;
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
{
var left = remaining.ToArray();
var states = new List<Region>();
foreach (var type in types)
{
if (left[type.Index] <= 0)
continue;
foreach (var o in type.Orientations)
if (Fits(o, work))
states.Add(new Region(o, work));
}
var placed = new List<Placed>();
var partArea = 0.0;
var front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0)
{
token.ThrowIfCancellationRequested();
var choice = Choose(states, front);
if (choice == null)
break;
var (region, point) = choice.Value;
var part = new Placed(region.Orientation, point.x, point.y);
placed.Add(part);
var typeIndex = region.Orientation.TypeIndex;
partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
if (--left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
// Each surviving region loses the positions the new part now blocks. Regions are
// independent, so they update in parallel without affecting determinism.
var snapshot = states.ToArray();
counter.Add(snapshot.Length);
Parallel.For(
0,
snapshot.Length,
new ParallelOptions { CancellationToken = token },
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
);
states.RemoveAll(s => s.IsEmpty);
}
return new SheetFill(stock, placed, partArea);
}
private (Region, PointD)? Choose(List<Region> states, double front)
{
Region? bestRegion = null;
var bestPoint = default(PointD);
var bestFills = false;
var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity;
foreach (var region in states)
{
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var area = types[region.Orientation.TypeIndex].Area;
var fills = advance <= Tie;
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
var better = bestRegion == null
|| (fills && !bestFills)
|| (
fills == bestFills
&& (
value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|| (
value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue))
&& (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie))
)
)
);
if (!better)
continue;
bestRegion = region;
bestPoint = point;
bestFills = fills;
bestValue = value;
bestSide = side;
bestLead = lead;
}
return bestRegion == null ? null : (bestRegion, bestPoint);
}
/// <summary>Legal reference points for one orientation on this sheet.</summary>
private sealed class Region
{
private readonly double minX, minY, maxX, maxY;
private PathsD free;
private RectD bounds;
public Region(Orientation orientation, Box work)
{
Orientation = orientation;
minX = work.Left - orientation.MinX;
maxX = work.Right - orientation.MaxX;
minY = work.Bottom - orientation.MinY;
maxY = work.Top - orientation.MaxY;
// Guard against fits that are infeasible by less than the bounds tolerance.
if (maxX < minX)
maxX = minX;
if (maxY < minY)
maxY = minY;
free = new PathsD
{
new PathD
{
new(minX - FitSlack, minY - FitSlack),
new(maxX + FitSlack, minY - FitSlack),
new(maxX + FitSlack, maxY + FitSlack),
new(minX - FitSlack, maxY + FitSlack),
},
};
bounds = Clipper.GetBounds(free);
}
public Orientation Orientation { get; }
public bool IsEmpty => free.Count == 0;
public void Subtract(Nfp nfp, double dx, double dy)
{
if (
nfp.Bounds.right + dx < bounds.left
|| nfp.Bounds.left + dx > bounds.right
|| nfp.Bounds.bottom + dy < bounds.top
|| nfp.Bounds.top + dy > bounds.bottom
)
return;
var clip = Clipper.TranslatePaths(nfp.Region, dx, dy);
free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision);
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
}
/// <summary>
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
/// then lowest leading edge. Vertices suffice because every score is linear in position.
/// </summary>
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
{
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
var o = Orientation;
foreach (var path in free)
foreach (var raw in path)
{
var x = System.Math.Clamp(raw.x, minX, maxX);
var y = System.Math.Clamp(raw.y, minY, maxY);
double reach, across, start;
if (axis == PackAxis.X)
{
reach = x + o.MaxX;
across = y + o.MinY;
start = x + o.MinX;
}
else
{
reach = y + o.MaxY;
across = x + o.MinX;
start = y + o.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
|| adv < advance - Tie
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
if (!better)
continue;
found = true;
point = new PointD(x, y);
advance = adv;
side = across;
lead = start;
}
return found;
}
}
}
@@ -1,171 +0,0 @@
using System.Collections.Concurrent;
using Clipper2Lib;
namespace OpenNest.Engine.Opus55;
/// <summary>
/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value.
///
/// Every placed part owns a footprint: its outline grown by half the required clearance
/// (plus its own chord tolerance). Two parts respect the clearance exactly when their
/// footprints do not overlap, so the whole spacing rule reduces to NFP containment.
/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once
/// per job and reused by every sheet, stock trial and strategy variant.
/// </summary>
internal sealed class NoFitCache
{
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
public const int Precision = 4;
private readonly double halfClearance;
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
private readonly ConcurrentDictionary<(int, int, int, int), Lazy<Nfp>> nfps = new();
public NoFitCache(double clearance)
{
halfClearance = clearance / 2;
}
public PathD Footprint(Orientation o) =>
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
/// <summary>NFP of <paramref name="moving"/> around <paramref name="fixedPart"/> placed at the origin.</summary>
public Nfp Get(Orientation fixedPart, Orientation moving) =>
nfps.GetOrAdd(
(fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index),
_ => new Lazy<Nfp>(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication)
)
.Value;
private PathD BuildFootprint(Orientation o)
{
// Miter joins (squared past the limit) always contain the exact round offset, so the
// footprint is a superset of "every point within the clearance of the outline".
var inflated = Clipper.InflatePaths(
new PathsD { o.Outline },
halfClearance + o.Tolerance,
JoinType.Miter,
EndType.Polygon,
2.0,
Precision,
0.0
);
var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First();
if (!Clipper.IsPositive(best))
best.Reverse();
return best;
}
private Nfp Build(Orientation fixedPart, Orientation moving)
{
var a = Footprint(fixedPart);
var b = Footprint(moving);
var negB = new PathD(b.Count);
foreach (var p in b)
negB.Add(new PointD(-p.x, -p.y));
PathsD region;
if (IsConvex(a) && IsConvex(b))
{
region = new PathsD { ConvexSum(a, negB) };
}
else
{
// A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
// copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
// (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
var sweep = Minkowski.Sum(negB, a, true, Precision);
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
}
return new Nfp(region, Clipper.GetBounds(region));
}
/// <summary>Minkowski sum of two convex CCW polygons by merging edges in angle order.</summary>
private static PathD ConvexSum(PathD a, PathD b)
{
var ia = LowestIndex(a);
var ib = LowestIndex(b);
var result = new PathD(a.Count + b.Count);
var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y);
int i = 0, j = 0;
while (i < a.Count || j < b.Count)
{
result.Add(current);
var ea = i < a.Count ? Edge(a, ia + i) : default;
var eb = j < b.Count ? Edge(b, ib + j) : default;
// Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi).
double order;
if (i >= a.Count)
order = -1;
else if (j >= b.Count)
order = 1;
else
{
var difference = EdgeAngle(eb) - EdgeAngle(ea);
order = System.Math.Abs(difference) < 1e-12 ? 0 : difference;
}
if (order > 0)
{
current = new PointD(current.x + ea.x, current.y + ea.y);
i++;
}
else if (order < 0)
{
current = new PointD(current.x + eb.x, current.y + eb.y);
j++;
}
else
{
current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y);
i++;
j++;
}
}
return result;
}
private static double EdgeAngle(PointD edge)
{
var angle = System.Math.Atan2(edge.y, edge.x);
return angle < 0 ? angle + System.Math.PI * 2 : angle;
}
private static PointD Edge(PathD path, int index)
{
var from = path[index % path.Count];
var to = path[(index + 1) % path.Count];
return new PointD(to.x - from.x, to.y - from.y);
}
/// <summary>Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle.</summary>
private static int LowestIndex(PathD path)
{
var best = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x))
best = i;
return best;
}
private static bool IsConvex(PathD path)
{
var n = path.Count;
if (n < 3)
return false;
for (var i = 0; i < n; i++)
{
var a = path[i];
var b = path[(i + 1) % n];
var c = path[(i + 2) % n];
var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
if (cross < -1e-12)
return false;
}
return true;
}
}
/// <summary>Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds.</summary>
internal sealed record Nfp(PathsD Region, RectD Bounds);
@@ -1,6 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Engines/Directory.Build.props. -->
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Engine.Opus55.Tests" />
</ItemGroup>
</Project>
@@ -1,294 +0,0 @@
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Opus55;
/// <summary>
/// Frontier-advance NFP packer with look-ahead stock selection.
///
/// Per sheet, <see cref="FrontierPacker"/> keeps the exact free region of every
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
/// either the largest part that fills a gap behind the packing front, or the part that advances
/// the front least per unit of area covered. Across sheets, every available stock size is
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
///
/// Fully deterministic: no clocks or randomness influence any decision.
/// </summary>
public sealed class Opus55NestingEngine : INestingEngine
{
/// <summary>
/// Extra clearance beyond the stock's part spacing, in job units. Validators polygonize arcs
/// circumscribed at 0.01 per side, so two tangent true arcs can read as up to 0.02 closer
/// than they are; the rest absorbs Clipper's 1e-4 grid and inner-fit clamping.
/// </summary>
internal const double ClearanceMargin = 0.022;
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
private static readonly (PackAxis Axis, double Beta)[] Variants =
{
(PackAxis.X, 1.0),
(PackAxis.Y, 1.0),
(PackAxis.X, 0.5),
(PackAxis.Y, 0.5),
(PackAxis.X, 1.5),
(PackAxis.Y, 1.5),
};
/// <summary>
/// Deterministic work budget, in free-region subtractions, after which no further variant
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
/// </summary>
internal long WorkBudget { get; init; } = 1_500_000;
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token);
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
var demand = new int[types.Count];
foreach (var type in types)
{
var placeable = job.Plates.Any(stock =>
stock.Quantity != 0
&& type.Orientations.Any(o => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock)))
);
demand[type.Index] = placeable ? type.Part.Quantity : 0;
}
Plan? best = null;
foreach (var (axis, beta) in Variants)
{
token.ThrowIfCancellationRequested();
if (best != null && solver.Work.Value >= WorkBudget)
break;
var plan = solver.Plan(demand, axis, beta);
if (best == null || plan.IsBetterThan(best))
best = plan;
if (best.Unplaced == 0 && best.Sheets.Count == 0)
break;
}
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
best = solver.ImproveTail(best!, WorkBudget * 2);
return BuildResult(job, types, best, progress);
}
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
private sealed class Solver(
NestJob job,
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
{
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
public WorkCounter Work { get; } = new();
private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea);
public Plan Plan(int[] demand, PackAxis axis, double beta)
{
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null);
var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count);
var reason = run.Reason;
if (unplaced > 0 && reason == NestJobStopReason.Completed)
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
}
/// <summary>
/// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with
/// every stock forced as the first sheet, under every variant; the cheapest complete
/// re-plan that beats the current tail replaces it. Tails are small and effort is metered.
/// </summary>
public Plan ImproveTail(Plan plan, long budget)
{
var sheets = plan.Sheets.ToList();
for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++)
{
if (Work.Value >= budget)
break;
var prefix = sheets.Take(sheets.Count - k).ToList();
var tail = sheets.Skip(sheets.Count - k).ToList();
var tailParts = tail.Sum(s => s.Parts.Count);
var tailNet = tail.Sum(s => SheetEconomics.NetArea(job.Options, s));
var tailDemand = new int[types.Count];
foreach (var part in tail.SelectMany(s => s.Parts))
tailDemand[part.Orientation.TypeIndex]++;
var used = prefix
.GroupBy(s => s.Stock.Id)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
Run? bestRun = null;
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
foreach (var (axis, beta) in Variants)
foreach (var first in job.Plates)
{
token.ThrowIfCancellationRequested();
var run = Decode(tailDemand, axis, beta, used, cap, first);
if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet)
continue;
bestRun = run;
bestNet = run.Net;
}
if (bestRun == null)
continue;
sheets = prefix.Concat(bestRun.Sheets).ToList();
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
}
return plan;
}
private NoFitCache CacheFor(NestPlateStock stock)
{
var clearance = System.Math.Max(0, stock.PartSpacing) + ClearanceMargin;
if (!caches.TryGetValue(clearance, out var cache))
caches[clearance] = cache = new NoFitCache(clearance);
return cache;
}
/// <summary>
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
/// <paramref name="first"/>, when set, forces the stock of the first sheet.
/// </summary>
private Run Decode(
int[] demand,
PackAxis axis,
double beta,
IReadOnlyDictionary<string, int> usedBefore,
int? sheetCap,
NestPlateStock? first
)
{
var remaining = (int[])demand.Clone();
var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal);
var sheets = new List<SheetFill>();
var net = 0.0;
NestJobStopReason reason;
while (true)
{
if (remaining.All(r => r == 0))
{
reason = NestJobStopReason.Completed;
break;
}
if (sheetCap is int cap && sheets.Count >= cap)
{
reason = NestJobStopReason.PlateLimitReached;
break;
}
var trials = new List<(SheetFill Fill, double Net)>();
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first))
continue;
if (stock.Quantity is int available && used[stock.Id] >= available)
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0)
trials.Add((fill, SheetEconomics.NetArea(job.Options, fill)));
}
if (trials.Count == 0)
{
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
break;
}
// Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial
// achieved, so a sheet that finishes the job competes fairly with a denser partial one.
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
var chosen = trials
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
.OrderBy(t => t.Estimate)
.ThenByDescending(t => t.Fill.Parts.Count)
.ThenBy(t => t.order)
.First();
sheets.Add(chosen.Fill);
net += chosen.Net;
used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--;
}
return new Run(sheets, net, reason);
}
}
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
private static NestJobResult BuildResult(
NestJob job,
IReadOnlyList<PartType> types,
Plan plan,
IProgress<NestJobProgress>? progress
)
{
var placed = new int[types.Count];
var plates = new List<NestJobPlateResult>(plan.Sheets.Count);
var committedParts = 0;
foreach (var sheet in plan.Sheets)
{
var placements = sheet.Parts.Select(p =>
{
var type = types[p.Orientation.TypeIndex];
return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation);
});
plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList()));
committedParts += sheet.Parts.Count;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts));
}
var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index]));
var usage = job.Plates.Select(stock =>
{
var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock));
return new StockUsage(stock.Id, count, stock.Quantity - count);
});
var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete;
return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList());
}
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
{
public bool IsBetterThan(Plan other)
{
if (Unplaced != other.Unplaced)
return Unplaced < other.Unplaced;
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
return Cost < other.Cost;
return Sheets.Count < other.Sheets.Count;
}
}
}
/// <summary>Deterministic effort meter shared by all packers in one solve.</summary>
internal sealed class WorkCounter
{
private long value;
public long Value => Interlocked.Read(ref value);
public void Add(long amount) => Interlocked.Add(ref value, amount);
}
@@ -1,274 +0,0 @@
using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Opus55;
/// <summary>
/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation
/// (reference point = snapshot origin), and the outline's conservative bounds.
/// </summary>
internal sealed class Orientation
{
public required int TypeIndex { get; init; }
public required int Index { get; init; }
public required double Rotation { get; init; }
/// <summary>CCW outline whose every point lies within <see cref="Tolerance"/> of the true perimeter.</summary>
public required PathD Outline { get; init; }
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
public required double Tolerance { get; init; }
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
public required double MinX { get; init; }
public required double MinY { get; init; }
public required double MaxX { get; init; }
public required double MaxY { get; init; }
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
}
internal sealed class PartType
{
public required int Index { get; init; }
public required NestJobPart Part { get; init; }
public required double Area { get; init; }
public required IReadOnlyList<Orientation> Orientations { get; init; }
}
/// <summary>
/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry
/// cannot be read are kept with no orientations, so they surface as unplaced instead of
/// failing the whole job.
/// </summary>
internal static class PartCatalog
{
/// <summary>Finest chord deviation of the working outline from true arcs, in job units.</summary>
public const double ChordTolerance = 0.002;
/// <summary>Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m).</summary>
private const int TargetVertices = 64;
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
private const int MaxOrientations = 8;
private const double TwoPi = System.Math.PI * 2;
public static IReadOnlyList<PartType> Build(NestJob job)
{
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
// pair costs a feasible-region update per placement.
var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations);
var types = new List<PartType>(job.Parts.Count);
for (var index = 0; index < job.Parts.Count; index++)
{
var part = job.Parts[index];
Shape? perimeter;
try
{
perimeter = ReadPerimeter(part.Geometry);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException)
{
perimeter = null;
}
if (perimeter == null)
{
types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] });
continue;
}
var angles = CandidateAngles(part.Rotation, perimeter, perType);
var tolerance = ChooseTolerance(perimeter);
var orientations = new List<Orientation>();
var signatures = new List<string>();
foreach (var angle in angles)
{
var outline = Polygonize(perimeter, angle, tolerance);
if (outline.Count < 3)
continue;
// Point-symmetric parts (rectangles, discs...) look identical at several angles;
// evaluating duplicates only costs time.
var signature = Signature(outline);
if (signatures.Contains(signature))
continue;
signatures.Add(signature);
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
}
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
}
return types;
}
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry)
{
var entities = ConvertProgram
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
.ToList();
if (entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
return profile.Perimeter is { } perimeter && perimeter.Area() > 1e-9 ? perimeter : null;
}
/// <summary>
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
/// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay.
/// </summary>
private static double ChooseTolerance(Shape perimeter)
{
var box = perimeter.BoundingBox;
var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length));
var tolerance = ChordTolerance;
while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices)
tolerance *= 2;
return tolerance;
}
private static PathD Polygonize(Shape perimeter, double angle, double tolerance)
{
var shape = (Shape)perimeter.Clone();
if (angle != 0)
shape.Rotate(angle);
var polygon = shape.ToPolygonWithTolerance(tolerance);
var path = new PathD(polygon.Vertices.Count);
foreach (var v in polygon.Vertices)
{
if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9)
continue;
path.Add(new PointD(v.X, v.Y));
}
if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9)
path.RemoveAt(path.Count - 1);
if (!Clipper.IsPositive(path))
path.Reverse();
return path;
}
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
{
var bounds = Clipper.GetBounds(outline);
return new Orientation
{
TypeIndex = typeIndex,
Index = index,
Rotation = angle,
Outline = outline,
Tolerance = tolerance,
MinX = bounds.left - tolerance,
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + tolerance,
MaxY = bounds.bottom + tolerance,
};
}
private static string Signature(PathD outline)
{
var bounds = Clipper.GetBounds(outline);
var points = outline
.Select(p => (System.Math.Round(p.x - bounds.left, 5), System.Math.Round(p.y - bounds.top, 5)))
.OrderBy(p => p.Item1)
.ThenBy(p => p.Item2)
.Select(p => $"{p.Item1:R},{p.Item2:R}");
return string.Join(";", points);
}
/// <summary>
/// Rotations to try, all satisfying the part's policy. Automatic parts get the four
/// right angles plus the two orientations that align their minimum-area bounding
/// rectangle with the sheet axes.
/// </summary>
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit)
{
var raw = new List<double>();
switch (policy.Kind)
{
case RotationPolicyKind.Fixed:
raw.Add(policy.Start);
if (policy.Allow180Equivalent)
raw.Add(policy.Start + System.Math.PI);
break;
case RotationPolicyKind.BoundedSweep:
{
var steps = (int)System.Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
var samples = System.Math.Min(steps + 1, policy.Allow180Equivalent ? System.Math.Max(1, limit / 2) : limit);
for (var i = 0; i < samples; i++)
{
var k = samples == 1 ? 0 : (int)System.Math.Round(i * (double)steps / (samples - 1));
raw.Add(policy.Start + k * policy.Step);
if (policy.Allow180Equivalent)
raw.Add(policy.Start + k * policy.Step + System.Math.PI);
}
break;
}
default:
{
var rightAngles = new[] { 0, System.Math.PI / 2, System.Math.PI, System.Math.PI * 1.5 };
var aligned = AlignedAngle(perimeter);
raw.Add(0);
raw.Add(System.Math.PI / 2);
if (aligned is double a)
{
raw.Add(Normalize(a));
raw.Add(Normalize(a + System.Math.PI / 2));
}
raw.Add(System.Math.PI);
raw.Add(System.Math.PI * 1.5);
if (aligned is double b)
{
raw.Add(Normalize(b + System.Math.PI));
raw.Add(Normalize(b + System.Math.PI * 1.5));
}
break;
}
}
var result = new List<double>();
foreach (var angle in raw)
{
if (!policy.Allows(angle))
continue;
if (result.Any(existing => SameTurn(existing, angle)))
continue;
result.Add(angle);
if (result.Count >= limit)
break;
}
return result;
}
private static double? AlignedAngle(Shape perimeter)
{
var polygon = perimeter.ToPolygonWithTolerance(ChordTolerance * 5);
if (polygon.Vertices.Count < 3)
return null;
var mbr = RotatingCalipers.MinimumBoundingRectangle(polygon.Vertices);
var angle = Normalize(-mbr.Angle) % (System.Math.PI / 2);
// Already axis-aligned (within ~0.05°): the right angles cover it.
if (angle < 1e-3 || System.Math.PI / 2 - angle < 1e-3)
return null;
return angle;
}
private static double Normalize(double angle)
{
var value = angle % TwoPi;
return value < 0 ? value + TwoPi : value;
}
private static bool SameTurn(double a, double b)
{
var delta = System.Math.Abs(Normalize(a - b));
return delta < 1e-9 || TwoPi - delta < 1e-9;
}
}
-97
View File
@@ -1,97 +0,0 @@
# OpenNest.Engine.Opus55
An independent whole-job `INestingEngine`: **frontier-advance NFP packing with look-ahead
stock selection**. It does not call, wrap, or select over any built-in engine
(`StockLadderNestingEngine`, `FixedStrategyNestingEngine` strategies, `PlateNesterFactory`,
`NestingEngineRegistry`), nor the removed `OpenNest.Engine/Nfp` bottom-left-fill/annealing code.
Every placement decision (which part, which rotation, where, on which sheet) comes from the logic below.
## Algorithm
**1. Geometry (`PartCatalog`, `NoFitCache`)**
- Each part's outer perimeter is polygonized with a known chord tolerance (0.002 by default,
coarsened for arc-heavy parts until the outline is ≤ ~64 vertices, capped at 0.1% of part size).
- Candidate rotations come from the part's `RotationPolicy`: for `Automatic`, the four right
angles plus the two orientations that axis-align the minimum-area bounding rectangle
(`RotatingCalipers`); for sweeps, up to 8 evenly spaced legal steps. Point-symmetric duplicates are dropped.
- Each orientation gets a **footprint**: outline inflated (miter joins, so it contains the exact
round offset) by `(spacing + 0.022) / 2 + chordTolerance`. Two parts respect the spacing
when their footprints don't overlap. The 0.022 covers validators that polygonize arcs
circumscribed at 0.01 per side, plus Clipper's 1e-4 grid.
- **No-fit polygons** between footprints come from Clipper2 Minkowski sums: an O(n+m)
edge merge for convex pairs, and for concave pairs the boundary sweep ∪ (A + p₀) ∪ (−B + a₀).
The last two terms cover "B inside A" and "B swallows A". NFPs are cached per orientation pair.
**2. Sheet filling (`FrontierPacker`)**
- For every (part type, orientation) still in play, the packer keeps the exact **free region** of
legal reference points: the inner-fit rectangle minus the NFPs of everything placed. Each
placement subtracts one translated NFP from each region (in parallel, which stays deterministic).
Regions only shrink, and an empty region is retired for the rest of the sheet.
- At every step all remaining types × orientations compete (there is no fixed placement sequence):
1. **Gap fill:** if any part fits without pushing the packing front forward, place the
*largest* such part at its lowest point.
2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the
most material coverage for the sheet length it consumes.
- The front sweeps along X or Y, which leaves one full-width offcut strip for salvage credit.
**3. Whole job (`Opus55NestingEngine`, `SheetEconomics`)**
- Sheet by sheet, every available stock size is trial-filled. The trial with the lowest
*estimated whole-job cost* (its net area, plus the remaining demand priced at the best
efficiency any trial achieved) is committed. This lets a sheet that finishes the job beat a
denser partial one.
- Net area = sheet area − `SalvageRate` × the largest qualifying full-width/full-length edge
offcut. This is the objective the benchmark scores.
- Six strategy variants (front axis X/Y × β ∈ {1, 0.5, 1.5}) each run whole-job, and the cheapest
plan wins (fewest unplaced, then cost, then sheets). A **tail re-plan** then re-decodes the
parts on the last 1–3 sheets with each stock forced first, and keeps any strictly cheaper result.
- **Deterministic:** no clock or randomness affects decisions. Effort is capped by a
count-based work budget (free-region subtractions), not wall time.
## Layout
| File | Role |
|---|---|
| `Opus55NestingEngine.cs` | `Solve()`: demand filtering, variants, stock look-ahead, tail re-plan, result assembly |
| `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule |
| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) |
| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation |
| `SheetEconomics.cs` | Net-area objective with salvage credit |
| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` |
## Build / test
```bash
dotnet build Engines/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj -c Release
dotnet test Engines/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
```
This project is intentionally **outside** `OpenNest.sln`, the same pattern as the
`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin.
## Benchmark
```bash
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
cp Engines/OpenNest.Engine.Opus55/bin/Release/net8.0/OpenNest.Engine.Opus55.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-manifest-or-folder>
```
Or build and deploy in one step with `./Engines/Build-Engines.ps1 -Engines Opus55`.
The engine reports as `Opus55NestingEngine`.
## Known limitations
- **No part-in-part:** holes are treated as solid, so small parts never nest inside cutouts.
- **Clearance padding:** gaps are ~0.022 (plus up to the chord tolerance) wider than the
required spacing, to stay valid under circumscribed-polygon validators. That's negligible in mm
and about 0.02" in inches. The constants are absolute and assume job units near inch/mm scale.
- **Rotation coverage:** `Automatic` parts try at most 8 orientations (fewer when a job has many
distinct parts: `48 / partCount`, minimum 2). Free-angle rotations aren't explored beyond the MBR alignment.
- **Greedy core:** there is no order/permutation search. The variants and tail re-plan are the only
search, and density on small mixed jobs trails what an interlocking-pair filler can reach.
- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate`
per trial and `PlateCommitted` at the end, with no finer-grained progress.
- 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.
@@ -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;
}
}
@@ -1,68 +0,0 @@
using System.Linq;
using Clipper2Lib;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Opus55.Tests;
public class NoFitCacheTests
{
[Theory]
[InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely.
[InlineData(-5.0, -5.0)] // A deep inside B.
[InlineData(2.0, 0.5)] // Partial overlap.
public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy)
{
var (small, big) = Orientations();
var nfp = new NoFitCache(0.1).Get(small, big);
Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden");
}
[Theory]
[InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance.
[InlineData(0.0, -21.0)] // Below A.
[InlineData(-21.0, 0.0)] // Left of A.
public void AllowsClearOffsets(double dx, double dy)
{
var (small, big) = Orientations();
var nfp = new NoFitCache(0.1).Get(small, big);
Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free");
}
/// <summary>A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left.</summary>
private static (Orientation Small, Orientation Big) Orientations()
{
var job = new NestJob(
new[]
{
new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)),
new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)),
},
new[] { new NestPlateStock("s", new Size(100, 100)) }
);
var types = PartCatalog.Build(job);
return (types[0].Orientations.Single(), types[1].Orientations.Single());
}
private static bool Forbidden(Nfp nfp, PointD point)
{
var winding = 0;
foreach (var path in nfp.Region)
if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside)
winding += Clipper.IsPositive(path) ? 1 : -1;
return winding != 0;
}
private static PartGeometrySnapshot Snapshot(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 PartGeometrySnapshot.FromProgram(program);
}
}
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.Opus55.csproj" />
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
<ProjectReference Include="../../../OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup>
</Project>
@@ -1,283 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Opus55.Tests;
public class Opus55NestingEngineTests
{
[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 Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
{
var job = Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
Part("slot", Obround(10, 3), 6),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void ZeroSpacingStillKeepsPartsApartForValidation()
{
var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) });
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void LargeAndSmallConcavePartsShareASheet()
{
// End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard).
var job = Job(
new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) },
new[] { Stock("sheet", 25, 45, spacing: 0.25) }
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void PicksTheCheaperSheetWhenItHoldsEverything()
{
var job = Job(
new[] { Part("square", Rectangle(10, 10), 4) },
new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) }
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("small", Assert.Single(result.Plates).StockId);
}
[Fact]
public void SpillsOntoAdditionalSheets()
{
var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) });
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1);
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i);
Assert.Equal(Enumerable.Range(0, 25), indices);
}
[Fact]
public void RespectsFixedAndBoundedRotationPolicies()
{
var fixedPolicy = RotationPolicy.Fixed(0);
var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4);
var job = Job(
new[]
{
Part("fixed", LShape(10, 6, 3), 8, fixedPolicy),
Part("swept", Triangle(8, 5), 8, sweep),
},
new[] { Stock("sheet", 40, 60, spacing: 0.25) }
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
{
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}");
}
}
[Fact]
public void OversizedPartIsReportedUnplacedWithoutBlockingOthers()
{
var job = Job(
new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) },
new[] { Stock("sheet", 20, 20) }
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed);
}
[Fact]
public void StopsWhenFiniteStockRunsOut()
{
var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) });
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(2, result.Plates.Count);
var usage = Assert.Single(result.StockUsage);
Assert.Equal(2, usage.Used);
Assert.Equal(0, usage.Remaining);
}
[Fact]
public void HonorsMaxPlates()
{
var job = Job(
new[] { Part("rect", Rectangle(9, 9), 20) },
new[] { Stock("sheet", 20, 20) },
new NestJobOptions(maxPlates: 1)
);
var result = new Opus55NestingEngine().Solve(job);
AssertValid(job, result);
Assert.Single(result.Plates);
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
}
[Fact]
public void IsDeterministic()
{
NestJob Build() =>
Job(
new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) },
new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) }
);
var first = new Opus55NestingEngine().Solve(Build());
var second = new Opus55NestingEngine().Solve(Build());
Assert.Equal(Describe(first), Describe(second));
}
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(Opus55NestingEngine));
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;
}
}
@@ -1,3 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Engines/Directory.Build.props. -->
</Project>
@@ -1,41 +0,0 @@
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Qwen;
/// <summary>
/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with
/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order
/// search", etc). This must be an independently designed algorithm — see README.md.
/// </summary>
public sealed class QwenNestingEngine : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
// TODO: implement independent placement logic here.
//
// Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, PlateFillService,
// or any built-in INestingEngine, and do not run several and keep the best. The
// Fill/ and pattern components (FillLinear, PairFiller, PatternTiler, Compactor, ...)
// and OpenNest.Core geometry ARE fair game as tools; the decisions are yours.
//
// job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy)
// job.Plates -> candidate stock sheets (size, spacing, quadrant, quantity)
// job.Options -> job-wide options
//
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id),
// and StockUsage (sheets used per stock id).
throw new NotImplementedException(
"Qwen nesting engine placement logic not yet implemented."
);
}
}
-70
View File
@@ -1,70 +0,0 @@
# OpenNest.Engine.Qwen
An independent `INestingEngine` implementation. It must not be a wrapper, ensemble, or
selector over OpenNest's built-in engines. `Solve()` must not call, instantiate, or
delegate to any existing `INestingEngine` (`StockLadderNestingEngine`,
`FixedStrategyNestingEngine`), `NestingEngineRegistry`, `NestJobRunner`, or the whole-plate
nesters/fillers behind `PlateNesterFactory` (`DefaultPlateNester`, `StripPlateNester`,
`RemnantPlateNester`, `PlateFillService`, `DefaultPlateFiller`, ...). It must also never run
several of them and keep the best result.
The decisions that make it an engine must be yours: which sheet(s) to use, which parts go
where and in what order, which pattern/strategy to apply to which region, and when to stop.
## Allowed building blocks
Reuse is encouraged. These are tools you drive, composed by your own decision logic:
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
`SpatialQuery`.
- Fill and pattern components in `OpenNest.Engine.Fill`: `FillLinear`, `FillExtents`,
`PairFiller`, `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor`, `FillScore`,
`Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`,
`BestCombination`.
- `OpenNest.Engine.BestFit` (`BestFitFinder`, `PairEvaluator`, ...), `RectanglePacking`,
`CirclePacking`.
If you find a faster or better way to do something a shared component already does (for
example linear patterning), implement it inside this engine's own project and leave the
shared code untouched. Do not edit `OpenNest.Core` or `OpenNest.Engine`. Call it out in your
report (what it replaces, why it is better, measured numbers) so it can be generalized and
upstreamed for every engine later.
## What to fill in
`QwenNestingEngine.cs` — implement `Solve()`. Pick and document an actual
placement strategy (NFP-based sliding placement, skyline/shelf packer,
simulated-annealing/genetic layout search, guillotine-cut packer,
physics/gravity-settling, etc). It's fine to be simpler or worse than the built-in
engines to start; it must not be the same algorithm re-derived through indirection.
## Build
```bash
dotnet build Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj
```
This project is intentionally **outside** `OpenNest.sln` (same pattern as the
`OpenNest.Engine.Aurora` plugin) — it's discovered at runtime as a plugin, not built
as part of the main solution.
## Try it out with the benchmark
`OpenNest.Benchmark` auto-loads plugin engines from an `Engines/` folder next to its
own build output:
```bash
dotnet build Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj -c Release
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
cp Engines/OpenNest.Engine.Qwen/bin/Release/net8.0/OpenNest.Engine.Qwen.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder>
```
Or build and deploy in one step with `./Engines/Build-Engines.ps1 -Engines Qwen`.
Your engine will show up in the report under its CLR type name (`QwenNestingEngine`),
competing on equal footing against the built-in engines.
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.Qwen.csproj" />
</ItemGroup>
</Project>
@@ -1,20 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen.Tests;
public class QwenNestingEngineTests
{
[Fact]
public void SolveReturnsAResultForASingleSimplePart()
{
// TODO: replace with a real fixture once Solve() is implemented — this only
// proves the plumbing (project reference, constructor, interface) is wired up.
var engine = new QwenNestingEngine();
Assert.NotNull(engine);
Assert.IsAssignableFrom<INestingEngine>(engine);
}
}
+1 -2
View File
@@ -59,7 +59,7 @@ dotnet run --project OpenNest.Benchmark -- ./benchmark-jobs \
--sheet-sizes 48x96,60x120,72x120 --engines Default,StockLadder --csv results.csv
```
Layouts are validated (bounds, spacing, quantity, rotation, stock match); invalid runs place nothing and pay the penalty. `--parallel` (default 3) speeds up scoring but inflates `Time(ms)` — use `--parallel 1` when comparing speed. Pass `--sheet-sizes` for an unbiased run; otherwise only each file's original sizes are offered. Custom engines drop in as DLLs implementing `INestingEngine` (public parameterless constructor) in an `Engines/` folder next to the benchmark; in-repo plugin engines live in the top-level `Engines/` source folder and build with `./Engines/Build-Engines.ps1`.
Layouts are validated (bounds, spacing, quantity, rotation, stock match); invalid runs place nothing and pay the penalty. `--parallel` (default 3) speeds up scoring but inflates `Time(ms)` — use `--parallel 1` when comparing speed. Pass `--sheet-sizes` for an unbiased run; otherwise only each file's original sizes are offered. Custom engines drop in as DLLs implementing `INestingEngine` (public parameterless constructor) in an `Engines/` folder next to the benchmark. Community engines live in [OpenNest-Engines](https://git.thecozycat.net/aj/OpenNest-Engines).
## Project Structure
@@ -75,7 +75,6 @@ Layouts are validated (bounds, spacing, quantity, rotation, stock match); invali
| **OpenNest.Benchmark** | Head-to-head engine comparison |
| **OpenNest.Mcp** | MCP server for AI tool integration |
| **OpenNest.Posts.Cincinnati** | Cincinnati laser post-processor plugin |
| **Engines/** | Out-of-solution plugin engines (`OpenNest.Engine.<Name>/`) |
| **\*.Tests** | Cross-platform suites; WinForms tests are Windows-only |
## Nesting Engines