diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs new file mode 100644 index 0000000..1ddf5cf --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs @@ -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(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing, + cancelled.Token)); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index 4492906..b3d7b50 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -30,7 +30,7 @@ internal static class CutoutLatticeFill /// Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets. internal const int DefaultShiftSteps = 8; - private const double FlattenTolerance = 0.001; + internal const double FlattenTolerance = 0.001; /// Extra growth beyond the spacing, covering flattening and Clipper rounding. 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). /// - 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 } /// Strictly inside an even-odd region; boundary points count as outside. - 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(); diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs new file mode 100644 index 0000000..b246f37 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs @@ -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; + +/// Test-only single-insert inner-fit proposal; never changes a job or a live plate. +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; + + /// 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. + 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; + + /// Sample component interiors, then points just inside each boundary toward + /// its component's center. This bounded search is not a complete inner-fit solver. + 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); + } + } +}