From 75195e8ba467b0587db5651d34f2eacb66f39556 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 10:40:04 -0400 Subject: [PATCH] fix(cutoffs): continue strip lines into a tail too short to keep When the end tail was shorter than the minimum tail to keep, the planner dropped the separator but still stopped the strip grid at it, so the last strip ran to the sheet end and could exceed the maximum spacing (e.g. a 39-inch strip from 105 to 144 with 35-inch spacing). The grid now spans the whole sheet whenever the separator is skipped, keeping every strip within the spacing. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/AutomaticCutOffPlanner.cs | 25 ++++++------ .../CutOffs/AutomaticCutOffPlannerTests.cs | 40 +++++++++++++++++-- docs/automatic-scrap-cutoffs.md | 4 +- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index ce5e0f74..d619d180 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -135,7 +135,11 @@ public static class AutomaticCutOffPlanner var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance; var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance; - var usedSpan = hasTailCandidate ? separator : length; + // A tail too short to keep stays attached to the skeleton, so the strip grid must + // continue to the sheet end; stopping at the skipped separator would leave a final + // strip longer than the spacing. + var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength; + var usedSpan = keepsTail ? separator : length; var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing); Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount, $"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options)); @@ -199,17 +203,14 @@ public static class AutomaticCutOffPlanner AddCandidate(sign * distance, false); } } - if (hasTailCandidate) - { - if (length - separator >= options.MinimumTailLength) - AddCandidate(sign * separator, true); - else - diagnostics.Add(new AutomaticCutOffDiagnostic( - AutomaticCutOffDiagnosticCode.TailBelowMinimum, - "The proposed tail is shorter than the minimum tail length; the final separator was skipped. " - + "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.", - X: sign * separator)); - } + if (keepsTail) + AddCandidate(sign * separator, true); + else if (hasTailCandidate) + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.TailBelowMinimum, + "The proposed tail is shorter than the minimum tail length; the final separator was skipped " + + "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.", + X: sign * separator)); if (diagnostics.Any(d => d.IsBlocking)) { diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index d0797475..93512a87 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -73,17 +73,18 @@ public class AutomaticCutOffPlannerTests [InlineData(2)] [InlineData(3)] [InlineData(4)] - public void MinimumTail_SkipsOnlyFinalSeparatorWithoutExtendingSkeletonGrid(int quadrant) + public void MinimumTail_SkippedSeparatorExtendsSkeletonGridToSheetEnd(int quadrant) { // The proposed separator is just before X=105, with a tail shorter than 16. - // Skipping it must not add the otherwise-next nominal line at X=105. + // Skipping it leaves the tail on the skeleton, so the grid must continue with + // the line at X=105; otherwise the final strip would run 70 to 120. var plate = MakePlate(quadrant, occupied: 104); - var before = Plan(plate); + var sign = quadrant is 2 or 3 ? -1 : 1; var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 }; var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); - Assert.Equal(before.Definitions.Take(before.Definitions.Count - 1).Select(c => c.Position.X), Positions(plan)); + Assert.Equal(new[] { 35.0, 70, 105 }.Select(x => sign * x), Positions(plan)); Assert.False(plan.HasSeparatedTail); Assert.Null(plan.TailSeparatorX); Assert.Equal(0, plan.TailLength); @@ -93,6 +94,37 @@ public class AutomaticCutOffPlannerTests Assert.Single(plate.Parts); } + [Theory] + [InlineData(1, null)] + [InlineData(1, 17.5)] + [InlineData(2, 17.5)] + [InlineData(3, 17.5)] + [InlineData(4, 17.5)] + public void MinimumTail_SkippedSeparatorKeepsEveryStripWithinSpacing(int quadrant, double? minimumStrip) + { + // A 144-long sheet used through 139.75 proposes a separator at 140 with a 4-long + // tail. With the separator skipped, no strip may exceed the 35 spacing. + var plate = new Plate(60, 144) { Quadrant = quadrant, PartSpacing = 0.25 }; + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.Parts.Add(Rectangle(138, 8, new Vector(quadrant is 2 or 3 ? -139.75 : 1.75, + quadrant is 3 or 4 ? -10 : 2))); + var options = new AutomaticCutOffOptions + { + Spacing = 35, + MinimumStripLength = minimumStrip, + MinimumTailLength = 12, + }; + + var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings()); + + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.TailBelowMinimum); + var boundaries = Positions(plan).Select(x => sign * x).Prepend(0).Append(144).OrderBy(x => x).ToArray(); + Assert.Equal(6, boundaries.Length); + Assert.All(boundaries.Zip(boundaries.Skip(1), (a, b) => b - a), + strip => Assert.InRange(strip, Tolerance.Epsilon, 35 + Tolerance.Epsilon)); + } + [Theory] [InlineData(1, -0.01, false)] [InlineData(1, 0, true)] diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index 41596f8a..91a53d23 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -6,7 +6,7 @@ Finish nesting, then choose **Nest > Automatic Scrap Cutoffs (All Plates)** to p 1. Finish or cancel any active nesting/fill operation first, and wait for it to finish stopping; closing its progress window alone does not finish the operation. Enter the maximum strip spacing along the sheet's length. The initial value is **35 in**, or **889 mm** for a metric nest. This is the maximum distance between new cut lines, not the length of a cut. 2. Set **Minimum strip**, initially half the spacing (**17.5 in** or **444.5 mm**). The first strip from the origin-side sheet edge and each strip between successive new lines stay between this minimum and the spacing; the last strip before the separator or sheet end can be shorter. This field follows spacing changes until you edit it. Setting it equal to spacing restores the fixed grid. -3. Set **Minimum tail to keep**, initially **12 in** or **304.8 mm**. This measures the reusable tail's length along X, after separator clearance, not its area. A shorter tail does not get a new final separator; a tail at or above the minimum can be retained. Zero allows any positive tail. +3. Set **Minimum tail to keep**, initially **12 in** or **304.8 mm**. This measures the reusable tail's length along X, after separator clearance, not its area. A shorter tail does not get a new final separator and is cut into strips with the rest of the skeleton; a tail at or above the minimum can be retained. Zero allows any positive tail. 4. Opening the modal dialog previews the initial proposal. After changing a setting, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. A bold green or red status line states what **Apply** will do; the occupied span, used span, separator X, retained tail, and strip cut length versus the fixed grid appear as measurements. The details list reports only issues and notices. The sheet cannot be edited while the dialog is open. 5. Choose **Apply** (or **Apply to All Plates**) to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of each plate's cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview. @@ -16,7 +16,7 @@ An empty sheet is left untouched. Invalid input or out-of-sheet part geometry pr ## Preserving the unused tail -A proposed tail shorter than **Minimum tail to keep** is left attached to the scrap skeleton by omitting only the final separator. The nominal skeleton grid is not extended into that tail, and no retained tail is claimed. Existing cutoff definitions—including an end cutoff from an earlier run—are never removed by this setting. +A proposed tail shorter than **Minimum tail to keep** is left attached to the scrap skeleton by omitting the final separator. The strip grid then continues to the sheet end exactly as it would with no room for a separator, so no strip exceeds the spacing, and no retained tail is claimed. For example, on a 144-inch sheet used through 139.75 inches, the 4-inch tail is too short to keep; the skeleton gets a fourth line near 140 inches instead of leaving a 39-inch final strip from 105 inches. Existing cutoff definitions—including an end cutoff from an earlier run—are never removed by this setting. Only real parts contribute to the occupied envelope; existing cutoff parts do not. When there is room, the final separator is beyond the furthest occupied extent by the larger of plate part spacing and cutoff part clearance, plus numerical tolerance. Repeated skeleton cuts stop before that separator. The larger margin affects only the separator: repeated cuts keep the existing manual-cutoff clearance behavior.