diff --git a/Directory.Build.props b/Directory.Build.props index 563d247..1a01b18 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,11 +4,13 @@ OpenNest.Engine./ with an optional tests/ subproject; both import this. AssemblyName and RootNamespace default to the project file name. - Engines build against an OpenNest checkout. By default that is a sibling clone - (../OpenNest next to this repo); override with /p:OpenNestRoot= or an - OpenNestRoot environment variable. + Engines build against an OpenNest checkout, found in this order: /p:OpenNestRoot= + or an OpenNestRoot environment variable; the parent folder, when these build files sit + in an Engines/ folder inside an OpenNest checkout; otherwise a sibling clone + (../OpenNest next to this repo). --> + $(MSBuildThisFileDirectory)../ $(MSBuildThisFileDirectory)../OpenNest/ $([MSBuild]::EnsureTrailingSlash('$(OpenNestRoot)')) net8.0 diff --git a/New-Engine.ps1 b/New-Engine.ps1 new file mode 100644 index 0000000..f744c78 --- /dev/null +++ b/New-Engine.ps1 @@ -0,0 +1,49 @@ +<# +.SYNOPSIS + Creates a new engine project from _Template/. + +.DESCRIPTION + Copies _Template/ to /OpenNest.Engine./ and replaces the __NAME__ + placeholder in file names and contents. The new engine builds against OpenNest via + Directory.Build.props, which must sit in or a parent folder. + + With -IncludeBuildFiles, Directory.Build.props/.targets are copied into + too, so the engine can live outside this repo, e.g. in an Engines/ folder inside an + OpenNest checkout (the props detect that layout on their own). + +.EXAMPLE + ./New-Engine.ps1 -Name Nova + ./New-Engine.ps1 -Name Nova -Destination C:/bench/run1/OpenNest/Engines -IncludeBuildFiles +#> +param( + [Parameter(Mandatory)] + [ValidatePattern('^[A-Z][A-Za-z0-9]*$')] + [string]$Name, + [string]$Destination = $PSScriptRoot, + [switch]$IncludeBuildFiles +) + +$ErrorActionPreference = 'Stop' +$template = Join-Path $PSScriptRoot '_Template' +$target = Join-Path $Destination "OpenNest.Engine.$Name" +if (Test-Path $target) { throw "'$target' already exists." } + +New-Item -ItemType Directory -Force $Destination | Out-Null +Copy-Item $template $target -Recurse + +Get-ChildItem $target -Recurse -File | ForEach-Object { + $text = [IO.File]::ReadAllText($_.FullName) + [IO.File]::WriteAllText($_.FullName, $text.Replace('__NAME__', $Name)) +} +# Deepest paths first so renaming a folder never invalidates a pending child path. +Get-ChildItem $target -Recurse | Where-Object Name -like '*__NAME__*' | + Sort-Object { $_.FullName.Length } -Descending | + ForEach-Object { Rename-Item $_.FullName $_.Name.Replace('__NAME__', $Name) } + +if ($IncludeBuildFiles) { + foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') { + Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force + } +} + +Write-Host "Created $target" diff --git a/README.md b/README.md index 35f333d..0544f06 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,23 @@ dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj Each engine's README covers its algorithm and benchmark results. +## Writing a new engine + +```powershell +./New-Engine.ps1 -Name Nova +``` + +This copies `_Template/` to `OpenNest.Engine.Nova/`: an `INestingEngine` stub, a README +spelling out what counts as an independent engine, and starter acceptance tests checked by +the benchmark's own `NestValidator` (they fail until `Solve()` is implemented). + +To work inside an OpenNest checkout instead, stamp it into an `Engines/` folder there and +bring the shared build files along; they detect that layout automatically: + +```powershell +./New-Engine.ps1 -Name Nova -Destination /Engines -IncludeBuildFiles +``` + ## Layout Each engine lives in `OpenNest.Engine./` with an optional `tests/` subproject. diff --git a/_Template/OpenNest.Engine.__NAME__.csproj b/_Template/OpenNest.Engine.__NAME__.csproj new file mode 100644 index 0000000..7de212c --- /dev/null +++ b/_Template/OpenNest.Engine.__NAME__.csproj @@ -0,0 +1,3 @@ + + + diff --git a/_Template/README.md b/_Template/README.md new file mode 100644 index 0000000..74d22cc --- /dev/null +++ b/_Template/README.md @@ -0,0 +1,72 @@ +# OpenNest.Engine.__NAME__ + +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`, `OpenNest.Engine`, or +`OpenNest.Benchmark`. 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 + +- `__NAME__NestingEngine.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. +- This README — replace this section with a description of the algorithm, its trade-offs, + and benchmark results. + +## Tests + +`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own +`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the +benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and +add engine-specific tests next to them. + +```bash +dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj +``` + +## Build and benchmark + +The project is a plugin outside `OpenNest.sln`. `OpenNest.Benchmark` loads plugin engines +from an `Engines/` folder next to its own build output: + +```bash +dotnet build OpenNest.Engine.__NAME__/OpenNest.Engine.__NAME__.csproj -c Release +dotnet build /OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release + +mkdir -p /OpenNest.Benchmark/bin/Release/net8.0/Engines +cp OpenNest.Engine.__NAME__/bin/Release/net8.0/OpenNest.Engine.__NAME__.dll /OpenNest.Benchmark/bin/Release/net8.0/Engines/ + +dotnet /OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll --parallel 1 +``` + +`` is the OpenNest checkout root. Your engine shows up in the report under its +CLR type name (`__NAME__NestingEngine`), competing on equal footing against the built-in +engines. diff --git a/_Template/__NAME__NestingEngine.cs b/_Template/__NAME__NestingEngine.cs new file mode 100644 index 0000000..59656de --- /dev/null +++ b/_Template/__NAME__NestingEngine.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.__NAME__; + +/// +/// 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 __NAME__NestingEngine : 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, edge spacing, quadrant, quantity) + // job.Options -> job-wide options + // + // Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding + // ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id), + // and StockUsage (sheets used per stock id). + + throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented."); + } +} diff --git a/_Template/tests/OpenNest.Engine.__NAME__.Tests.csproj b/_Template/tests/OpenNest.Engine.__NAME__.Tests.csproj new file mode 100644 index 0000000..0efd585 --- /dev/null +++ b/_Template/tests/OpenNest.Engine.__NAME__.Tests.csproj @@ -0,0 +1,17 @@ + + + false + true + + + + + + + + + + + + + diff --git a/_Template/tests/__NAME__NestingEngineTests.cs b/_Template/tests/__NAME__NestingEngineTests.cs new file mode 100644 index 0000000..b120e7a --- /dev/null +++ b/_Template/tests/__NAME__NestingEngineTests.cs @@ -0,0 +1,150 @@ +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.__NAME__.Tests; + +/// +/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark +/// scores with, so a passing test means the benchmark will accept the layout. They fail until +/// Solve() is implemented; add engine-specific tests alongside them. +/// +public class __NAME__NestingEngineTests +{ + [Fact] + public void HasPublicParameterlessConstructorForPluginDiscovery() + { + var engine = Activator.CreateInstance(typeof(__NAME__NestingEngine)); + Assert.IsAssignableFrom(engine); + } + + [Fact] + public void RectanglesFitOnOneSheetWithSpacing() + { + var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) }); + + var result = new __NAME__NestingEngine().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), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) } + ); + + var result = new __NAME__NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void OverflowSpillsOntoAdditionalSheets() + { + var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) }); + + var result = new __NAME__NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.True(result.Plates.Count > 1); + } + + [Fact] + public void PartTooBigForAnySheetIsReportedUnplaced() + { + var job = Job( + new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) }, + new[] { Stock("sheet", 20, 20, spacing: 0.25) } + ); + + var result = new __NAME__NestingEngine().Solve(job); + + AssertValid(job, result); + var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); + Assert.Equal(1, huge.Unplaced); + } + + // ---- helpers ------------------------------------------------------------------------- + + private static void AssertValid(NestJob job, NestJobResult result) + { + var materialized = NestResultMaterializer.Materialize(job, result); + var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList(); + var requirements = job.Parts.ToDictionary( + 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; + } +}