diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs index 2111705c..6930a494 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -67,6 +67,45 @@ public class CutoutRouterTests AssertPhysical(frame, new[] { insert }, poses); } + [Fact] + public void NfpAddsOneMoreCopyToPartialShiftedLattice() + { + // Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted + // lattice keeps 11; an independently certified NFP pose occupies its leftover. + var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); + var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12); + var lattice = CutoutLatticeFill.Fill(frame, 0, insert, 12, 0.25); + Assert.Equal(11, lattice.Count); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Equal(12, poses.Count); + Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex)); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy() + { + // 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy. + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3); + Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Equal(2, poses.Count); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); + var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0)); + Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio); + Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25)); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Single(poses); + AssertPhysical(frame, new[] { insert }, poses); + } + [Fact] public void DuplicateRequirementIdsAreRejectedBeforePlacement() { @@ -149,8 +188,34 @@ public class CutoutRouterTests Assert.InRange(ry, 5.25, 14.75); } else - Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= 4.75 + 0.00001); + { + var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right); + Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001); + } } } + // Independent rectangle-vs-rectangle distance, not the production Clears predicate. + for (var i = 0; i < poses.Count; i++) + for (var j = i + 1; j < poses.Count; j++) + { + var a = Bounds(poses[i]); + var b = Bounds(poses[j]); + var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right)); + var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top)); + Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001, + $"independent material gap too small: {poses[i]} vs {poses[j]}"); + } + + (double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose) + { + var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds; + var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom), + (box.Right, box.Top), (box.Left, box.Top) }; + var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation) + - c.Item2 * System.Math.Sin(pose.Rotation)).ToArray(); + var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation) + + c.Item2 * System.Math.Cos(pose.Rotation)).ToArray(); + return (x.Min(), x.Max(), y.Min(), y.Max()); + } } } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index c47f9391..78fdf9d2 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -7,13 +7,17 @@ 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. +/// Area ratio selects search order, not placement permission. The small-insert path +/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search; +/// the large-insert path tries NFP first. All accepted poses are clearance-checked. +/// Fill's equal-score pose nondeterminism remains PM c98c21bd. internal static class CutoutRouter { + // Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests + // show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies. + internal const double SmallRatio = 0.10; + internal const double LargeRatio = 0.35; + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, IReadOnlyList inserts, double spacing, CancellationToken token = default) { @@ -28,8 +32,7 @@ internal static class CutoutRouter ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); var hole = frameGeometry.Cutouts[cutoutIndex]; - if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) - || !Supported(hole.BoundingBox)) + if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox)) return Array.Empty(); if (inserts.Any(p => p == null || !SupportedGeometry(p))) return Array.Empty(); @@ -40,42 +43,75 @@ internal static class CutoutRouter var accepted = new List(); var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(); + var holeArea = System.Math.Abs(hole.Area()); // 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) + var ratio = geometry.MaterialArea / holeArea; + // Unsupported or degenerate material cannot establish a useful routing ratio; + // NFP retains its own fail-closed checks and the bounded lattice is not tried. + var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0; + var nfpFirst = !tryLattice || ratio >= LargeRatio; + var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio; + var nfp = nfpFirst ? Nfp(Array.Empty()) : null; + var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity) + ? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing, + CutoutLatticeFill.DefaultShiftSteps, occupied, token) + : Array.Empty(); + IReadOnlyList chosen; + if (compareBoth || (nfpFirst && lattice.Count > 0)) { - // 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); - } + nfp ??= Nfp(Array.Empty()); + var fromLattice = Complete(lattice); + chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp; } - while (accepted.Count(p => p.PartId == part.Id) < part.Quantity) + else if (nfpFirst) + chosen = nfp!; + else { - 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) }); + // A single Fill copy ties one NFP pose; prefer NFP's stable candidate + // order, but retain the lattice copy if bounded NFP found nothing. + var seed = lattice.Count > 1 ? lattice : Array.Empty(); + chosen = Complete(seed); + if (chosen.Count == 0 && lattice.Count == 1) + chosen = lattice; } - void Accept(NestJobPlacement pose) + foreach (var pose in chosen) { + token.ThrowIfCancellationRequested(); var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; accepted.Add(indexed); occupied.Add((geometry, indexed)); } + + IReadOnlyList Complete(IReadOnlyList seed) + { + if (seed.Count >= part.Quantity) + return seed; + var result = new List(seed); + result.AddRange(Nfp(seed)); + return result; + } + + IReadOnlyList Nfp(IReadOnlyList seed) + { + var result = new List(); + var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied); + local.AddRange(seed.Select(p => (geometry, p))); + while (seed.Count + result.Count < part.Quantity) + { + token.ThrowIfCancellationRequested(); + var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token); + if (pose == null) + break; + result.Add(pose); + local.Add((geometry, pose)); + } + return result; + } } token.ThrowIfCancellationRequested(); return accepted.ToArray(); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 6b92139b..a81268a1 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -89,8 +89,14 @@ original requirement IDs, reindexes accepted copies and checks clearance against 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 +ratio cutoff rejects a possible placement: the measured 0.10 and 0.35 ratios guide +search order only. Below 0.10 it starts with Fill for three or more copies; between +0.10 and 0.35 it compares Fill-plus-NFP with NFP-only counts; above 0.35 it tries +NFP first, then Fill if demand remains, taking the higher-count valid proposal. +An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. +Six geometry-only, anonymized real-job probes and neutral ring fixtures informed +the search-order hints; these limited cases are not a global density guarantee. +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