feat: add _Template and New-Engine.ps1 for starting new engines

Each engine-building run started from a hand-copied scaffold (Qwen's),
which carried that engine's name and stale notes. _Template holds the
generic scaffold with a __NAME__ placeholder, and New-Engine.ps1 stamps
out a named copy. The template's starter tests go through the
benchmark's NestValidator, so a model gets a real pass/fail target
instead of a plumbing-only check; they were verified to pass against
Opus55.

Directory.Build.props now also detects when it sits in an Engines/
folder inside an OpenNest checkout, so an engine stamped there with
-IncludeBuildFiles builds without passing OpenNestRoot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-24 10:10:24 -04:00
co-authored by Claude Opus 5.5
parent cfce484952
commit 70ae0cc155
8 changed files with 352 additions and 3 deletions
+5 -3
View File
@@ -4,11 +4,13 @@
OpenNest.Engine.<Name>/ with an optional tests/ subproject; both import this. OpenNest.Engine.<Name>/ with an optional tests/ subproject; both import this.
AssemblyName and RootNamespace default to the project file name. AssemblyName and RootNamespace default to the project file name.
Engines build against an OpenNest checkout. By default that is a sibling clone Engines build against an OpenNest checkout, found in this order: /p:OpenNestRoot=<path>
(../OpenNest next to this repo); override with /p:OpenNestRoot=<path> or an or an OpenNestRoot environment variable; the parent folder, when these build files sit
OpenNestRoot environment variable. in an Engines/ folder inside an OpenNest checkout; otherwise a sibling clone
(../OpenNest next to this repo).
--> -->
<PropertyGroup> <PropertyGroup>
<OpenNestRoot Condition="'$(OpenNestRoot)' == '' and Exists('$(MSBuildThisFileDirectory)../OpenNest.Engine/OpenNest.Engine.csproj')">$(MSBuildThisFileDirectory)../</OpenNestRoot>
<OpenNestRoot Condition="'$(OpenNestRoot)' == ''">$(MSBuildThisFileDirectory)../OpenNest/</OpenNestRoot> <OpenNestRoot Condition="'$(OpenNestRoot)' == ''">$(MSBuildThisFileDirectory)../OpenNest/</OpenNestRoot>
<OpenNestRoot>$([MSBuild]::EnsureTrailingSlash('$(OpenNestRoot)'))</OpenNestRoot> <OpenNestRoot>$([MSBuild]::EnsureTrailingSlash('$(OpenNestRoot)'))</OpenNestRoot>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
+49
View File
@@ -0,0 +1,49 @@
<#
.SYNOPSIS
Creates a new engine project from _Template/.
.DESCRIPTION
Copies _Template/ to <Destination>/OpenNest.Engine.<Name>/ and replaces the __NAME__
placeholder in file names and contents. The new engine builds against OpenNest via
Directory.Build.props, which must sit in <Destination> or a parent folder.
With -IncludeBuildFiles, Directory.Build.props/.targets are copied into <Destination>
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"
+17
View File
@@ -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. 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 <OpenNest>/Engines -IncludeBuildFiles
```
## Layout ## Layout
Each engine lives in `OpenNest.Engine.<Name>/` with an optional `tests/` subproject. Each engine lives in `OpenNest.Engine.<Name>/` with an optional `tests/` subproject.
@@ -0,0 +1,3 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
</Project>
+72
View File
@@ -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>/OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines
cp OpenNest.Engine.__NAME__/bin/Release/net8.0/OpenNest.Engine.__NAME__.dll <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder> --parallel 1
```
`<OpenNest>` is the OpenNest checkout root. Your engine shows up in the report under its
CLR type name (`__NAME__NestingEngine`), competing on equal footing against the built-in
engines.
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.__NAME__;
/// <summary>
/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with
/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order
/// search", etc). This must be an independently designed algorithm — see README.md.
/// </summary>
public sealed class __NAME__NestingEngine : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
// TODO: implement independent placement logic here.
//
// Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, PlateFillService,
// or any built-in INestingEngine, and do not run several and keep the best. The
// Fill/ and pattern components (FillLinear, PairFiller, PatternTiler, Compactor, ...)
// and OpenNest.Core geometry ARE fair game as tools; the decisions are yours.
//
// job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy)
// job.Plates -> candidate stock sheets (size, spacing, 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.");
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.__NAME__.csproj" />
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
/// Solve() is implemented; add engine-specific tests alongside them.
/// </summary>
public class __NAME__NestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(__NAME__NestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new __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<NestJobPart, Drawing, (string Name, int Quantity)>(
p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(runs, requirements);
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
foreach (var f in result.Fulfillment)
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
}
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
new(parts, stock, options);
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
private static NestPlateStock Stock(
string id,
double width,
double length,
double spacing = 0,
Spacing edge = default,
int quadrant = 1,
int? quantity = null
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
private static Program Polyline(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return program;
}
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
private static Program Disc(double r)
{
var program = new Program();
program.Codes.Add(new RapidMove(r, 0));
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
return program;
}
}