feat(cutouts): propose certified single inserts with inner-fit geometry

This commit is contained in:
aj committed 2026-10-09 16:59:11 -04:00
1 parent 60fb009329
commit 575412a464
3 files changed
+244 -4

No files matched your search

@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -30,7 +30,7 @@ internal static class CutoutLatticeFill
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
internal const int DefaultShiftSteps = 8;
private const double FlattenTolerance = 0.001;
internal const double FlattenTolerance = 0.001;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001;
@@ -134,7 +134,7 @@ internal static class CutoutLatticeFill
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
/// never meets the cutout boundary (t outside boundary + reflected outline).
/// </summary>
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
{
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities)
@@ -203,7 +203,7 @@ internal static class CutoutLatticeFill
}
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y)
internal static bool Inside(PathsD region, double x, double y)
{
var point = new PointD(x, y);
var inside = false;
@@ -218,7 +218,7 @@ internal static class CutoutLatticeFill
return inside;
}
private static PathD Positive(PathD path)
internal static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse();
@@ -0,0 +1,86 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
internal static class CutoutNfpProposal
{
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
// spacing values allocate an unbounded number of arc vertices. This conservative
// test-only kernel declines scales it cannot process safely.
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
double spacing, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
token.ThrowIfCancellationRequested();
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return null;
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
return null;
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
return null;
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return null;
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var rotation in insert.Rotation.EnumerateAngles())
{
token.ThrowIfCancellationRequested();
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
token.ThrowIfCancellationRequested();
foreach (var (x, y) in Candidates(region))
{
token.ThrowIfCancellationRequested();
if (!CutoutLatticeFill.Inside(region, x, y))
continue;
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
if (NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
return pose;
}
}
token.ThrowIfCancellationRequested();
return null;
}
private static bool WithinRange(Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
/// <summary>Sample component interiors, then points just inside each boundary toward
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
{
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
{
var bounds = Clipper.GetBounds(new PathsD { path });
var cx = (bounds.left + bounds.right) / 2;
var cy = (bounds.top + bounds.bottom) / 2;
yield return (cx, cy);
foreach (var point in path)
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
}
}
}