From e3d10e95aec9af1ad6b17e3b72e93ca6a133967f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 24 Sep 2026 10:02:29 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- Engines/Build-Engines.ps1 | 40 -- Engines/Directory.Build.props | 15 - Engines/Directory.Build.targets | 10 - .../AstraNestingEngine.cs | 150 -------- .../OpenNest.Engine.Astra/ContactGeometry.cs | 64 ---- .../OpenNest.Engine.Astra/ContactPlacer.cs | 268 -------------- .../OpenNest.Engine.Astra.csproj | 6 - .../OpenNest.Engine.Astra/PreparedGeometry.cs | 187 ---------- Engines/OpenNest.Engine.Astra/README.md | 125 ------- .../benchmarks/GeneratedCases.cs | 89 ----- .../OpenNest.Engine.Astra.Benchmarks.csproj | 7 - .../benchmarks/Program.cs | 94 ----- .../benchmarks/README.md | 182 --------- .../benchmarks/dxf/locked.manifest.json | 20 - .../benchmarks/dxf/mixed.manifest.json | 25 -- .../benchmarks/dxf/original.manifest.json | 20 - .../benchmarks/dxf/volume.manifest.json | 20 - .../benchmarks/results/baseline-dxf.csv | 5 - .../benchmarks/results/baseline-generated.csv | 24 -- .../benchmarks/results/baseline-synthetic.csv | 12 - .../benchmarks/results/contact-dxf.csv | 5 - .../benchmarks/results/contact-generated.csv | 24 -- .../benchmarks/results/contact-synthetic.csv | 12 - .../results/ring-validator-fixed.txt | 8 - .../results/ring-validator-reproducer.txt | 8 - .../results/validator-fixed-dxf.csv | 5 - .../validator-fixed-synthetic-generated.csv | 35 -- .../tests/AstraNestingEngineTests.cs | 347 ------------------ .../tests/OpenNest.Engine.Astra.Tests.csproj | 16 - .../tests/real-dxf.manifest.json | 9 - .../OpenNest.Engine.Opus55/FrontierPacker.cs | 272 -------------- Engines/OpenNest.Engine.Opus55/NoFitCache.cs | 171 --------- .../OpenNest.Engine.Opus55.csproj | 6 - .../Opus55NestingEngine.cs | 294 --------------- Engines/OpenNest.Engine.Opus55/PartCatalog.cs | 274 -------------- Engines/OpenNest.Engine.Opus55/README.md | 97 ----- .../OpenNest.Engine.Opus55/SheetEconomics.cs | 41 --- .../tests/NoFitCacheTests.cs | 68 ---- .../tests/OpenNest.Engine.Opus55.Tests.csproj | 17 - .../tests/Opus55NestingEngineTests.cs | 283 -------------- .../OpenNest.Engine.Qwen.csproj | 3 - .../OpenNest.Engine.Qwen/QwenNestingEngine.cs | 41 --- Engines/OpenNest.Engine.Qwen/README.md | 70 ---- .../tests/OpenNest.Engine.Qwen.Tests.csproj | 15 - .../tests/QwenNestingEngineTests.cs | 20 - README.md | 3 +- 47 files changed, 2 insertions(+), 3507 deletions(-) delete mode 100644 Engines/Build-Engines.ps1 delete mode 100644 Engines/Directory.Build.props delete mode 100644 Engines/Directory.Build.targets delete mode 100644 Engines/OpenNest.Engine.Astra/AstraNestingEngine.cs delete mode 100644 Engines/OpenNest.Engine.Astra/ContactGeometry.cs delete mode 100644 Engines/OpenNest.Engine.Astra/ContactPlacer.cs delete mode 100644 Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj delete mode 100644 Engines/OpenNest.Engine.Astra/PreparedGeometry.cs delete mode 100644 Engines/OpenNest.Engine.Astra/README.md delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/Program.cs delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/README.md delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv delete mode 100644 Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv delete mode 100644 Engines/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs delete mode 100644 Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj delete mode 100644 Engines/OpenNest.Engine.Astra/tests/real-dxf.manifest.json delete mode 100644 Engines/OpenNest.Engine.Opus55/FrontierPacker.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/NoFitCache.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj delete mode 100644 Engines/OpenNest.Engine.Opus55/Opus55NestingEngine.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/PartCatalog.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/README.md delete mode 100644 Engines/OpenNest.Engine.Opus55/SheetEconomics.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs delete mode 100644 Engines/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj delete mode 100644 Engines/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs delete mode 100644 Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj delete mode 100644 Engines/OpenNest.Engine.Qwen/QwenNestingEngine.cs delete mode 100644 Engines/OpenNest.Engine.Qwen/README.md delete mode 100644 Engines/OpenNest.Engine.Qwen/tests/OpenNest.Engine.Qwen.Tests.csproj delete mode 100644 Engines/OpenNest.Engine.Qwen/tests/QwenNestingEngineTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3a59bd3..aa637fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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./` 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//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//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`. diff --git a/Engines/Build-Engines.ps1 b/Engines/Build-Engines.ps1 deleted file mode 100644 index b3c3fa3..0000000 --- a/Engines/Build-Engines.ps1 +++ /dev/null @@ -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" -} diff --git a/Engines/Directory.Build.props b/Engines/Directory.Build.props deleted file mode 100644 index eb217da..0000000 --- a/Engines/Directory.Build.props +++ /dev/null @@ -1,15 +0,0 @@ - - - - net8.0 - enable - enable - - - - - diff --git a/Engines/Directory.Build.targets b/Engines/Directory.Build.targets deleted file mode 100644 index 8c753ea..0000000 --- a/Engines/Directory.Build.targets +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/Engines/OpenNest.Engine.Astra/AstraNestingEngine.cs b/Engines/OpenNest.Engine.Astra/AstraNestingEngine.cs deleted file mode 100644 index a866253..0000000 --- a/Engines/OpenNest.Engine.Astra/AstraNestingEngine.cs +++ /dev/null @@ -1,150 +0,0 @@ -using OpenNest.Engine.Jobs; -using M = System.Math; - -namespace OpenNest.Engine.Astra; - -/// Independent configuration-space contact packing with bounded stock-plan search. -public sealed class AstraNestingEngine : INestingEngine -{ - public NestJobResult Solve(NestJob job, IProgress? 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(), 0); - var frontier = new List { initial }; - var best = initial; - Plan? complete = IsComplete(initial) ? initial : null; - var trials = new Dictionary(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(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(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(); - 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(); - 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 Sheets, double Cost); -} diff --git a/Engines/OpenNest.Engine.Astra/ContactGeometry.cs b/Engines/OpenNest.Engine.Astra/ContactGeometry.cs deleted file mode 100644 index d3e9f2c..0000000 --- a/Engines/OpenNest.Engine.Astra/ContactGeometry.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Clipper2Lib; -using OpenNest.Geometry; -using M = System.Math; - -namespace OpenNest.Engine.Astra; - -/// Per-solve configuration-space cache, never a shared mutable geometry cache. -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)))); - } -} diff --git a/Engines/OpenNest.Engine.Astra/ContactPlacer.cs b/Engines/OpenNest.Engine.Astra/ContactPlacer.cs deleted file mode 100644 index 81e7c3b..0000000 --- a/Engines/OpenNest.Engine.Astra/ContactPlacer.cs +++ /dev/null @@ -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 Shapes, double Area, double Span); - -/// Searches vertices of the available translation region and exact-fit contacts. -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(); - var spaces = new Dictionary(); - 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 placed, double width, double height, - double spacing, int mode, double right, double top, Dictionary 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(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 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 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; - } - -} diff --git a/Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj b/Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj deleted file mode 100644 index 724216d..0000000 --- a/Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/Engines/OpenNest.Engine.Astra/PreparedGeometry.cs b/Engines/OpenNest.Engine.Astra/PreparedGeometry.cs deleted file mode 100644 index bfd3073..0000000 --- a/Engines/OpenNest.Engine.Astra/PreparedGeometry.cs +++ /dev/null @@ -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 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 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 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(); - var keys = new HashSet(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 Angles(RotationPolicy policy, ShapeProfile profile) - { - var values = new List(); - 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().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(); - 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; - } - } -} diff --git a/Engines/OpenNest.Engine.Astra/README.md b/Engines/OpenNest.Engine.Astra/README.md deleted file mode 100644 index f92a641..0000000 --- a/Engines/OpenNest.Engine.Astra/README.md +++ /dev/null @@ -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. diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs b/Engines/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs deleted file mode 100644 index d7069f5..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs +++ /dev/null @@ -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(); - 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; - } -} diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj b/Engines/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj deleted file mode 100644 index 3f698ce..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - Exedisable - - - - - diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/Program.cs b/Engines/OpenNest.Engine.Astra/benchmarks/Program.cs deleted file mode 100644 index 6211edc..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/Program.cs +++ /dev/null @@ -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); } -} diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/README.md b/Engines/OpenNest.Engine.Astra/benchmarks/README.md deleted file mode 100644 index 8f11656..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/README.md +++ /dev/null @@ -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 -``` diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json b/Engines/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json deleted file mode 100644 index 4f0ab13..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json +++ /dev/null @@ -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 - } - ] -} \ No newline at end of file diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json b/Engines/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json deleted file mode 100644 index 19e5afd..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json +++ /dev/null @@ -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 - } - ] -} \ No newline at end of file diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json b/Engines/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json deleted file mode 100644 index 3b66ed7..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json +++ /dev/null @@ -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 - } - ] -} \ No newline at end of file diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json b/Engines/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json deleted file mode 100644 index 3dd9a78..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json +++ /dev/null @@ -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 - } - ] -} \ No newline at end of file diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv deleted file mode 100644 index 886a6f4..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv +++ /dev/null @@ -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, diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv deleted file mode 100644 index 5c42d43..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv +++ /dev/null @@ -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 diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv deleted file mode 100644 index c6fca17..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv +++ /dev/null @@ -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 diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv deleted file mode 100644 index 76a07ae..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv +++ /dev/null @@ -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, diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv deleted file mode 100644 index 7a09661..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv +++ /dev/null @@ -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 diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv deleted file mode 100644 index 08644e2..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv +++ /dev/null @@ -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 diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt b/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt deleted file mode 100644 index 12c9c26..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt +++ /dev/null @@ -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: diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt b/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt deleted file mode 100644 index dc81f51..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt +++ /dev/null @@ -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) diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv deleted file mode 100644 index a9994d9..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv +++ /dev/null @@ -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, diff --git a/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv b/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv deleted file mode 100644 index 741eb88..0000000 --- a/Engines/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv +++ /dev/null @@ -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 diff --git a/Engines/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs b/Engines/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs deleted file mode 100644 index cc92143..0000000 --- a/Engines/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs +++ /dev/null @@ -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())).StopReason); - Assert.Equal(NestJobStatus.Complete, new AstraNestingEngine().Solve( - new NestJob(Array.Empty(), Array.Empty())).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(() => new AstraNestingEngine().Solve(job, token: cts.Token)); - using var during = new CancellationTokenSource(); - Assert.Throws(() => 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(); - 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 callback) : IProgress - { 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); - } - } -} diff --git a/Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj b/Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj deleted file mode 100644 index ae3dfab..0000000 --- a/Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - false - true - - - - - - - - - - - - diff --git a/Engines/OpenNest.Engine.Astra/tests/real-dxf.manifest.json b/Engines/OpenNest.Engine.Astra/tests/real-dxf.manifest.json deleted file mode 100644 index a904c27..0000000 --- a/Engines/OpenNest.Engine.Astra/tests/real-dxf.manifest.json +++ /dev/null @@ -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 } - ] -} diff --git a/Engines/OpenNest.Engine.Opus55/FrontierPacker.cs b/Engines/OpenNest.Engine.Opus55/FrontierPacker.cs deleted file mode 100644 index 63c8f61..0000000 --- a/Engines/OpenNest.Engine.Opus55/FrontierPacker.cs +++ /dev/null @@ -1,272 +0,0 @@ -using Clipper2Lib; -using OpenNest.Engine.Jobs; -using OpenNest.Geometry; - -namespace OpenNest.Engine.Opus55; - -/// Direction the packing front sweeps across the sheet (the free strip is left behind it). -internal enum PackAxis -{ - /// Front moves in +X; parts settle toward low X, then low Y. - X, - - /// Front moves in +Y; parts settle toward low Y, then low X. - 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 Parts, double PartArea); - -/// -/// 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. -/// -internal sealed class FrontierPacker -{ - /// 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. - private const double FitSlack = 2e-4; - - private const double Tie = 1e-6; - - private readonly IReadOnlyList 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 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 - ); - } - - /// True when the orientation's bounds fit the work area at all (Box.Length is the X extent). - public static bool Fits(Orientation o, Box work) => - o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9; - - public SheetFill Fill(IReadOnlyList remaining, CancellationToken token) - { - var left = remaining.ToArray(); - var states = new List(); - 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(); - 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 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); - } - - /// Legal reference points for one orientation on this sheet. - 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); - } - - /// - /// 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. - /// - 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; - } - } -} diff --git a/Engines/OpenNest.Engine.Opus55/NoFitCache.cs b/Engines/OpenNest.Engine.Opus55/NoFitCache.cs deleted file mode 100644 index 09fa25a..0000000 --- a/Engines/OpenNest.Engine.Opus55/NoFitCache.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Collections.Concurrent; -using Clipper2Lib; - -namespace OpenNest.Engine.Opus55; - -/// -/// 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. -/// -internal sealed class NoFitCache -{ - /// Clipper decimal precision; 1e-4 job units is far below any margin we keep. - 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> nfps = new(); - - public NoFitCache(double clearance) - { - halfClearance = clearance / 2; - } - - public PathD Footprint(Orientation o) => - footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o)); - - /// NFP of around placed at the origin. - public Nfp Get(Orientation fixedPart, Orientation moving) => - nfps.GetOrAdd( - (fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index), - _ => new Lazy(() => 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)); - } - - /// Minkowski sum of two convex CCW polygons by merging edges in angle order. - 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); - } - - /// Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle. - 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; - } -} - -/// Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds. -internal sealed record Nfp(PathsD Region, RectD Bounds); diff --git a/Engines/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj b/Engines/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj deleted file mode 100644 index c9a747c..0000000 --- a/Engines/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/Engines/OpenNest.Engine.Opus55/Opus55NestingEngine.cs b/Engines/OpenNest.Engine.Opus55/Opus55NestingEngine.cs deleted file mode 100644 index e0cfd4e..0000000 --- a/Engines/OpenNest.Engine.Opus55/Opus55NestingEngine.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System; -using System.Threading; -using OpenNest.Engine.Jobs; - -namespace OpenNest.Engine.Opus55; - -/// -/// Frontier-advance NFP packer with look-ahead stock selection. -/// -/// Per sheet, 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. -/// -public sealed class Opus55NestingEngine : INestingEngine -{ - /// - /// 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. - /// - internal const double ClearanceMargin = 0.022; - - /// Strategy variants, tried in order: (front direction, area exponent beta). - 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), - }; - - /// - /// Deterministic work budget, in free-region subtractions, after which no further variant - /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. - /// - internal long WorkBudget { get; init; } = 1_500_000; - - public NestJobResult Solve( - NestJob job, - IProgress? 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); - } - - /// Shared state for one solve: job, catalog, NFP caches, effort meter. - private sealed class Solver( - NestJob job, - IReadOnlyList types, - IProgress? progress, - CancellationToken token - ) - { - private const int MaxTail = 3; - private readonly Dictionary 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(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); - } - - /// - /// 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. - /// - 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; - } - - /// - /// Greedy sheet-by-sheet decode. seeds finite-stock - /// accounting, bounds the sheets this run may add, and - /// , when set, forces the stock of the first sheet. - /// - private Run Decode( - int[] demand, - PackAxis axis, - double beta, - IReadOnlyDictionary 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(); - 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 Sheets, double Net, NestJobStopReason Reason); - - private static NestJobResult BuildResult( - NestJob job, - IReadOnlyList types, - Plan plan, - IProgress? progress - ) - { - var placed = new int[types.Count]; - var plates = new List(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 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; - } - } -} - -/// Deterministic effort meter shared by all packers in one solve. -internal sealed class WorkCounter -{ - private long value; - public long Value => Interlocked.Read(ref value); - public void Add(long amount) => Interlocked.Add(ref value, amount); -} diff --git a/Engines/OpenNest.Engine.Opus55/PartCatalog.cs b/Engines/OpenNest.Engine.Opus55/PartCatalog.cs deleted file mode 100644 index 6402a17..0000000 --- a/Engines/OpenNest.Engine.Opus55/PartCatalog.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -internal sealed class Orientation -{ - public required int TypeIndex { get; init; } - public required int Index { get; init; } - public required double Rotation { get; init; } - - /// CCW outline whose every point lies within of the true perimeter. - public required PathD Outline { get; init; } - - /// Chord deviation used for arcs; footprints are grown by it to stay conservative. - public required double Tolerance { get; init; } - - /// Outline bounds grown by the tolerance, so they contain the true perimeter. - 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 Orientations { get; init; } -} - -/// -/// 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. -/// -internal static class PartCatalog -{ - /// Finest chord deviation of the working outline from true arcs, in job units. - public const double ChordTolerance = 0.002; - - /// Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m). - private const int TargetVertices = 64; - - /// Hard cap on distinct orientations evaluated per part type. - private const int MaxOrientations = 8; - - private const double TwoPi = System.Math.PI * 2; - - public static IReadOnlyList 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(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(); - var signatures = new List(); - 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; - } - - /// - /// 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. - /// - 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); - } - - /// - /// 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. - /// - internal static List CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) - { - var raw = new List(); - 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(); - 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; - } -} diff --git a/Engines/OpenNest.Engine.Opus55/README.md b/Engines/OpenNest.Engine.Opus55/README.md deleted file mode 100644 index b0f3cdb..0000000 --- a/Engines/OpenNest.Engine.Opus55/README.md +++ /dev/null @@ -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 -``` - -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. diff --git a/Engines/OpenNest.Engine.Opus55/SheetEconomics.cs b/Engines/OpenNest.Engine.Opus55/SheetEconomics.cs deleted file mode 100644 index 4ef3af9..0000000 --- a/Engines/OpenNest.Engine.Opus55/SheetEconomics.cs +++ /dev/null @@ -1,41 +0,0 @@ -using OpenNest.Engine.Jobs; - -namespace OpenNest.Engine.Opus55; - -/// -/// 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 ) is what makes that offcut large. -/// -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; - } -} diff --git a/Engines/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs b/Engines/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs deleted file mode 100644 index 5f1c093..0000000 --- a/Engines/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs +++ /dev/null @@ -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"); - } - - /// A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left. - 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); - } -} diff --git a/Engines/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj b/Engines/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj deleted file mode 100644 index 74d976a..0000000 --- a/Engines/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - false - true - - - - - - - - - - - - - diff --git a/Engines/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs b/Engines/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs deleted file mode 100644 index 4beb32b..0000000 --- a/Engines/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs +++ /dev/null @@ -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(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( - 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); - - /// Y extent. - /// X extent. - 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; - } - - /// Stadium: two semicircular ends joined by straight sides, offset from the origin. - 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; - } -} diff --git a/Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj b/Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj deleted file mode 100644 index 1c0be6e..0000000 --- a/Engines/OpenNest.Engine.Qwen/OpenNest.Engine.Qwen.csproj +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Engines/OpenNest.Engine.Qwen/QwenNestingEngine.cs b/Engines/OpenNest.Engine.Qwen/QwenNestingEngine.cs deleted file mode 100644 index 8b7bbe6..0000000 --- a/Engines/OpenNest.Engine.Qwen/QwenNestingEngine.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Threading; -using OpenNest.Engine.Jobs; - -namespace OpenNest.Engine.Qwen; - -/// -/// 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. -/// -public sealed class QwenNestingEngine : INestingEngine -{ - public NestJobResult Solve( - NestJob job, - IProgress? 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." - ); - } -} diff --git a/Engines/OpenNest.Engine.Qwen/README.md b/Engines/OpenNest.Engine.Qwen/README.md deleted file mode 100644 index 4a76745..0000000 --- a/Engines/OpenNest.Engine.Qwen/README.md +++ /dev/null @@ -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 -``` - -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. diff --git a/Engines/OpenNest.Engine.Qwen/tests/OpenNest.Engine.Qwen.Tests.csproj b/Engines/OpenNest.Engine.Qwen/tests/OpenNest.Engine.Qwen.Tests.csproj deleted file mode 100644 index 1deae75..0000000 --- a/Engines/OpenNest.Engine.Qwen/tests/OpenNest.Engine.Qwen.Tests.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - false - true - - - - - - - - - - - diff --git a/Engines/OpenNest.Engine.Qwen/tests/QwenNestingEngineTests.cs b/Engines/OpenNest.Engine.Qwen/tests/QwenNestingEngineTests.cs deleted file mode 100644 index 4043aee..0000000 --- a/Engines/OpenNest.Engine.Qwen/tests/QwenNestingEngineTests.cs +++ /dev/null @@ -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(engine); - } -} diff --git a/README.md b/README.md index 3a76f84..38ebe0a 100644 --- a/README.md +++ b/README.md @@ -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./`) | | **\*.Tests** | Cross-platform suites; WinForms tests are Windows-only | ## Nesting Engines