feat(rectangles): add rectangle-lane maximal-rectangles engine

Nests every part as the axis-aligned box of its material at its minimum-area
rotation and packs boxes with a maximal-rectangles free list (6 placement rules x
global/ordered pick). Sheet choice uses the salvage-credited look-ahead cost.

Curved extremes read the way the layout check sees them (circumscribed arcs on
the Clipper grid), so discs, rings and obrounds stay valid at box contact; an
80-job sweep failed 16-50 jobs before that rule.

Local 2026 production lanes (--min-salvage-dimension 12, --parallel 3):
- 91 all-rectangular jobs: 91/91 valid, cost 333081 vs best general 332153
  (Gpt6Astra, 89/91 valid), 5.9 s vs 13.7 s (Opus55) and 169.5 s (Gpt6Astra)
- 77 box-filling (>= 90%) jobs: 77/77 valid, lowest total cost 641637
This commit is contained in:
aj
2026-09-29 20:49:00 -04:00
parent 33cc2ee810
commit 13478f5102
10 changed files with 900 additions and 0 deletions
@@ -0,0 +1,31 @@
using OpenNest.Engine.Testing;
using static OpenNest.Engine.Testing.JobBuilder;
using static OpenNest.Engine.Testing.Shapes;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Rectangles.Tests;
/// <summary>
/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check
/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the
/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that.
/// </summary>
public class CurvedExtremeSweepTests
{
[Fact]
public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing()
{
var bad = new List<string>(); var n = 0;
foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 })
foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 })
foreach (var rot in new[] { false, true })
foreach (var ring in new[] { false, true })
{
var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0);
var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) },
new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) });
var res = new RectanglesNestingEngine().Solve(job); n++;
var v = NestLayoutCheck.Violations(job, res);
if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}");
}
Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad));
}
}
@@ -0,0 +1,16 @@
<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="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.Rectangles.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,157 @@
using OpenNest.Engine.Testing;
using static OpenNest.Engine.Testing.JobBuilder;
using static OpenNest.Engine.Testing.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Rectangles.Tests;
/// <summary>
/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck 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 RectanglesNestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine));
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 RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced);
}
[Fact]
public void ExactFitGridPacksAtExactlyThePartSpacing()
{
// 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16.
var job = Job(new[] { Part("r", Rectangle(10, 5), 12) },
new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(12, Assert.Single(result.Plates).Placements.Count);
}
[Fact]
public void ArcExtremePartsStayClearAtTheSpacing()
{
// Discs and obrounds have arcs, not vertices, at their box edges: the validator's
// circumscribed flattening would read box-touching copies as closer than the spacing.
var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) },
new[] { Stock("s", 30, 40, spacing: 0.25) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void MixedSizesFillOneSheetThatShelfPackingWouldSplit()
{
// Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920.
// A maximal-rectangles packing fits all of it on one sheet with zero spacing.
var job = Job(new[]
{
Rectangle("big", 24, 20, 2, RotationPolicy.Automatic),
Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic),
Rectangle("small", 6, 5, 8, RotationPolicy.Automatic),
},
new[] { Stock("s", 40, 48) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void RotatedInputIsNestedAtItsMinimumBoundingRectangle()
{
// A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet.
var c = System.Math.Cos(System.Math.PI / 6);
var s = System.Math.Sin(System.Math.PI / 6);
(double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5);
var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4));
var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) },
new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) });
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
}
public sealed class RectanglesContractTests : EngineContractTests<RectanglesNestingEngine> { }