From 962ae8c82515e09ea8ba40f8b60a2c3989da8078 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 17:20:41 -0400 Subject: [PATCH] feat(cutouts): propose bounded mixed lattice and NFP inserts --- .../Jobs/Cutouts/CutoutRouterTests.cs | 156 ++++++++++++++++++ .../Jobs/Cutouts/CutoutLatticeFill.cs | 40 ++++- .../Jobs/Cutouts/CutoutNfpProposal.cs | 30 +++- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 95 +++++++++++ docs/nesting-engines.md | 10 ++ 5 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs new file mode 100644 index 0000000..2111705 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -0,0 +1,156 @@ +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 CutoutRouterTests +{ + private static NestJobPart Frame() + { + 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); + return JobBuilder.Part("frame", program, 1); + } + + [Fact] + public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3); + var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0)); + Assert.Equal(3, CutoutLatticeFill.Fill(frame, 0, small, 3, 0.25).Count); + var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); + Assert.Equal(3, poses.Count(p => p.PartId == small.Id)); + Assert.Single(poses, p => p.PartId == large.Id); + Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex)); + AssertPhysical(frame, new[] { small, large }, poses); + } + + [Fact] + public void ImpossibleRemainderNeverDisplacesCertifiedCopies() + { + var frame = Frame(); + var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0)); + var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0)); + var alone = CutoutRouter.Fill(frame, 0, new[] { small }, 0.25); + var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25); + Assert.Equal(alone, together); + Assert.Equal(3, together.Count); + AssertPhysical(frame, new[] { small, impossible }, together); + } + + [Fact] + public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch() + { + var frame = Frame(); + var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0)); + var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); + Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity); + Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity); + AssertPhysical(frame, new[] { small, large }, poses); + } + + [Fact] + public void ResidualNfpDoesNotOverlapTheFirstLargeInsert() + { + var frame = Frame(); + var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Single(poses); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void DuplicateRequirementIdsAreRejectedBeforePlacement() + { + var frame = Frame(); + var insert = JobBuilder.Rectangle("same", 1, 1, 3); + Assert.Throws(() => CutoutRouter.Fill(frame, 0, + new[] { insert, insert }, 0.25)); + } + + [Fact] + public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1); + var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3); + var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25); + Assert.Equal(3, early.Count); + var blocked = early[0] with { PartId = first.Id }; + var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) }; + var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25, + CutoutLatticeFill.DefaultShiftSteps, occupied, default); + Assert.Equal(3, later.Count); + Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked, + JobPartGeometry.Read(more.Geometry), p, 0.25))); + } + + [Fact] + public void TinyInsertDoesNotAllocateAnUnboundedLattice() + { + var frame = Frame(); + var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0)); + Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0); + Assert.Equal(3, poses.Count); + } + + [Fact] + public void CancellationIsNotNoFit() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.Throws(() => CutoutRouter.Fill(Frame(), 0, + new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token)); + } + + private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts, + IReadOnlyList poses) + { + var stock = JobBuilder.Stock("sheet", 22, 22, 0.25); + var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock }); + var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds; + var offsetX = 1 - frameBounds.Left; + var offsetY = 1 - frameBounds.Bottom; + var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) } + .Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray(); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, placements) }, + new[] { new PartFulfillment(frame.Id, 1, 1, 0) } + .Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity, + poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id)))) + .ToArray(), + new[] { new StockUsage(stock.Id, 1, null) }); + LayoutAssert.Valid(job, result); + var frameGeometry = JobPartGeometry.Read(frame.Geometry); + var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0); + foreach (var pose in poses) + { + var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry); + Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25)); + // Independent square-wall oracle: every corner of each rectangular insert clears the hole. + var bounds = geometry.Bounds; + foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom), + (bounds.Right, bounds.Top), (bounds.Left, bounds.Top) }) + { + 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); + if (frameBounds.Left >= 0) + { + Assert.InRange(rx, 5.25, 14.75); + Assert.InRange(ry, 5.25, 14.75); + } + else + Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= 4.75 + 0.00001); + } + } + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index b3d7b50..44b903c 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -36,6 +36,9 @@ internal static class CutoutLatticeFill private const double Margin = FlattenTolerance + 0.001; private const int Precision = NestTolerances.ClipperPrecision; + // Fill materializes an entire grid before quantity trimming. Bound work even for a + // tiny insert with demand of only three, rather than allocating millions of clones. + private const int MaxLatticePositions = 1000; /// Returns up to insert poses inside the cutout, /// or none when no copy fits. @@ -45,9 +48,17 @@ internal static class CutoutLatticeFill internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token) + => Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps, + Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token); + + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, + IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(insert); + ArgumentNullException.ThrowIfNull(occupied); + token.ThrowIfCancellationRequested(); ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps); if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0) return Array.Empty(); @@ -69,6 +80,10 @@ internal static class CutoutLatticeFill var bounds = cutout.BoundingBox; var insertBounds = insertGeometry.Bounds; var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing; + var estimated = (System.Math.Ceiling(bounds.Length / step) + 4) + * (System.Math.Ceiling(bounds.Width / step) + 4); + if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions) + return Array.Empty(); var lattice = Lattice(insert, bounds, step, spacing, token); if (lattice.Count == 0) @@ -83,7 +98,8 @@ internal static class CutoutLatticeFill { token.ThrowIfCancellationRequested(); var (dx, dy) = Shift(step, shiftSteps, i, j); - var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)); + var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy) + && ClearsOccupied(p, dx, dy)); if (Better(count, i, j, best)) best = (count, i, j); } @@ -91,13 +107,33 @@ internal static class CutoutLatticeFill return Array.Empty(); var (sx, sy) = Shift(step, shiftSteps, best.I, best.J); - var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)) + var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy) + && ClearsOccupied(p, sx, sy)) .Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8), System.Math.Round(p.Y + sy, 8), p.Rotation)) .OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation) .ToList(); return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity) .Select((p, index) => p with { InstanceIndex = index }).ToArray(); + + bool ClearsOccupied(NestJobPlacement pose, double dx, double dy) + { + token.ThrowIfCancellationRequested(); + if (occupied.Count == 0) + return true; + var moved = pose with + { + X = System.Math.Round(pose.X + dx, 8), + Y = System.Math.Round(pose.Y + dy, 8) + }; + foreach (var other in occupied) + { + token.ThrowIfCancellationRequested(); + if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing)) + return false; + } + return true; + } } /// Fill over the cutout's bounds grown by one step on every side, in frame diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs index b246f37..9871e7c 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs @@ -20,10 +20,18 @@ internal static class CutoutNfpProposal /// 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) + double spacing, CancellationToken token = default) => + Find(frame, cutoutIndex, insert, spacing, + Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token); + + /// Find a pose clearing already accepted inserts as well as the enclosing frame. + internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert, + double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(insert); + ArgumentNullException.ThrowIfNull(occupied); token.ThrowIfCancellationRequested(); if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing) return null; @@ -55,7 +63,19 @@ internal static class CutoutNfpProposal 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)) + if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing)) + continue; + var clears = true; + foreach (var other in occupied) + { + token.ThrowIfCancellationRequested(); + if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing)) + continue; + clears = false; + break; + } + token.ThrowIfCancellationRequested(); + if (clears) return pose; } } @@ -81,6 +101,12 @@ internal static class CutoutNfpProposal 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); + // Bounded interior samples let a residual insert land beside a partial lattice; + // a center/vertex-only sample often lands directly on an earlier copy. + for (var y = 1; y <= 9; y++) + for (var x = 1; x <= 9; x++) + yield return (bounds.left + (bounds.right - bounds.left) * x / 10, + bounds.top + (bounds.bottom - bounds.top) * y / 10); } } } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs new file mode 100644 index 0000000..c47f939 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -0,0 +1,95 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. +/// Tries shifted partial Fill for a type with at least three requested copies, +/// prefers it only when it beats a single NFP pose, then fills residual demand +/// with NFP poses. No tuned ratio cutoff is claimed: the +/// historical 0.10/0.20 routing thresholds still require measured acceptance. +/// Fill's equal-score pose nondeterminism is tracked as PM c98c21bd. +internal static class CutoutRouter +{ + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(inserts); + token.ThrowIfCancellationRequested(); + if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100) + return Array.Empty(); + var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + if (frameGeometry == null) + return Array.Empty(); + ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); + var hole = frameGeometry.Cutouts[cutoutIndex]; + if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) + || !Supported(hole.BoundingBox)) + return Array.Empty(); + if (inserts.Any(p => p == null || !SupportedGeometry(p))) + return Array.Empty(); + if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count + || inserts.Any(p => p.Id == frame.Id)) + throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.", + nameof(inserts)); + + var accepted = new List(); + var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(); + // Preserve caller requirement order: priorities are a whole-job concern, not inferred here. + foreach (var part in inserts) + { + token.ThrowIfCancellationRequested(); + var geometry = JobPartGeometry.Read(part.Geometry); + if (part.Quantity >= 3) + { + // Geometric work is bounded inside Fill. Do not infer a ratio threshold + // from material area before measuring cutout jobs; try both proposals. + var usable = CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, + spacing, CutoutLatticeFill.DefaultShiftSteps, occupied, token); + // A one-copy lattice adds no value over the geometry-aware NFP search; + // retain the latter's stable candidate order for that tie. + if (usable.Count > 1 || (usable.Count == 1 + && CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token) == null)) + foreach (var pose in usable) + { + token.ThrowIfCancellationRequested(); + Accept(pose); + } + } + while (accepted.Count(p => p.PartId == part.Id) < part.Quantity) + { + token.ThrowIfCancellationRequested(); + var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token); + if (pose == null) + break; + Accept(pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }); + } + + void Accept(NestJobPlacement pose) + { + var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; + accepted.Add(indexed); + occupied.Add((geometry, indexed)); + } + } + token.ThrowIfCancellationRequested(); + return accepted.ToArray(); + } + + private static bool SupportedGeometry(NestJobPart part) + { + var geometry = JobPartGeometry.TryRead(part.Geometry); + return geometry != null && Supported(geometry.Bounds); + } + + private static bool Supported(OpenNest.Geometry.Box box) => + double.IsFinite(box.Left) && double.IsFinite(box.Right) + && double.IsFinite(box.Bottom) && double.IsFinite(box.Top) + && System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8 + && System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8; +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 05ae758..6b92139 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -83,6 +83,16 @@ layout check uses. The method suits many small copies in a large cutout; a few l meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated calls for some parts, so results are not yet guaranteed identical between runs. +The internal `CutoutRouter` can propose copies from the shifted lattice, then search bounded +inner-fit/NFP sample points for remaining copies and other insert requirements. It keeps +original requirement IDs, reindexes accepted copies and checks clearance against the frame +and every previously accepted insert. Lattice shifts account for occupied poses before +quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling +try instead. A null NFP proposal is not proof of geometric impossibility. No material-area +ratio cutoff is enabled: the proposed 0.10/0.20 routing thresholds still need measured +neutral and sanitized-job evidence. The router has no pipeline caller and does not change +stock, live demand, cutting order or posting safety; in-hole production use remains held. + ## Renamed engines The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved