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:
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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,
|
||||
|
-35
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user