From bde332fb10dfaa43af553c7433beef5bc77acdf3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 10:19:55 -0400 Subject: [PATCH] fix(core): check equivalent existing cutoffs against clamp spans The automatic cutoff planner tested only the proposed X against active clamp spans, then certified a tolerance-close existing line at its own X. A manual line just inside a clamp edge could therefore be reported as a verified tail separator. Test the proposed X and every equivalent existing definition before regenerating. Also keep a same-line limited manual cut blocking the whole plan when it lies on a clamp, as it did before clamp checks were added. Project Memory: opennest 51597040-cff6-4304-87dc-3c2a13edf45a --- OpenNest.Core/AutomaticCutOffPlanner.cs | 37 +++++++----- .../CutOffs/AutomaticCutOffPlannerTests.cs | 58 ++++++++++++++++++- docs/automatic-scrap-cutoffs.md | 2 +- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index 62beb0a..d65559f 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -173,24 +173,9 @@ public static class AutomaticCutOffPlanner void AddCandidate(double x, bool isSeparator) { - // A full-width cut crosses every active front-edge clamp at this X. Test - // even equivalent existing definitions: a stale manual line cannot certify - // a retained tail. Keep the whole footprint when it hangs off the sheet. - var clearance = plate.EdgeSpacing.Bottom; - var blocked = plate.ClampZones.FirstOrDefault(zone => - x >= zone.Bounds.Left - clearance && x <= zone.Bounds.Right + clearance); - if (blocked != null) - { - diagnostics.Add(new AutomaticCutOffDiagnostic( - AutomaticCutOffDiagnosticCode.ClampBlockedCut, - $"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.", - X: x)); - if (isSeparator) - WarnNoSeparator(x); - return; - } var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)).ToList(); + if (matches.Any(c => !FullSpanLimits(c, bounds, settings))) { diagnostics.Add(new AutomaticCutOffDiagnostic( @@ -202,6 +187,26 @@ public static class AutomaticCutOffPlanner return; } + // A full-width cut crosses every active front-edge clamp at its X. Test the + // proposed X and every equivalent existing definition, because a tolerance-close + // existing line is regenerated and certified at its own X. Keep the whole + // footprint when it hangs off the sheet. A same-line manual limit conflict above + // still blocks the whole plan, clamp or not. + var clearance = plate.EdgeSpacing.Bottom; + var blocked = plate.ClampZones.FirstOrDefault(zone => + matches.Select(c => c.Position.X).Prepend(x).Any(position => + position >= zone.Bounds.Left - clearance && position <= zone.Bounds.Right + clearance)); + if (blocked != null) + { + diagnostics.Add(new AutomaticCutOffDiagnostic( + AutomaticCutOffDiagnosticCode.ClampBlockedCut, + $"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.", + X: x)); + if (isSeparator) + WarnNoSeparator(x); + return; + } + // Even a duplicate must be regenerated detached with CURRENT settings. Its live // drawing can be stale, and a tolerance-close line can intersect a part at the tail. var existing = matches.FirstOrDefault(); diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index c4b027b..f79c81e 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -244,6 +244,62 @@ public class AutomaticCutOffPlannerTests Assert.Same(existing, Assert.Single(plate.CutOffs)); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void NearEquivalentExistingSeparatorInsideClampCannotCertifyTail(int quadrant) + { + // The nominal separator (80.50001) lies just beyond the clamp edge (80.5000075), + // but the equivalent existing line that would be certified (80.500005) is inside it. + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + var y = quadrant is 3 or 4 ? -0.625 : 0; + plate.ClampZones.Add(new ClampZone("edge", sign > 0 + ? new Box(79.500005, y, 1.0000025, 0.625) + : new Box(-80.5000075, y, 1.0000025, 0.625))); + var existing = new CutOff(new Vector(sign * 80.500005, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(existing); + existing.Regenerate(plate, new CutOffSettings()); + var original = existing.Drawing.Program; + + var plan = Plan(plate); + + Assert.False(plan.HasSeparatedTail); + Assert.Null(plan.TailSeparatorX); + Assert.Equal(0, plan.TailLength); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff); + Assert.Equal(new[] { sign * 35.0, sign * 70.0 }, Positions(plan)); + Assert.Same(original, existing.Drawing.Program); + Assert.Same(existing, Assert.Single(plate.CutOffs)); + } + + [Theory] + [InlineData(35)] + [InlineData(80.50001)] + public void SameLineLimitedManualCutOnClamp_StillBlocksWholePlan(double x) + { + var plate = MakePlate(); + plate.ClampZones.Add(new ClampZone("under-manual", new Box(x - 0.75, 0, 1.5, 0.625))); + var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical) { StartLimit = 10, EndLimit = 60 }; + plate.CutOffs.Add(manual); + var program = manual.Drawing.Program; + + var plan = Plan(plate); + + Assert.True(plan.HasBlockingDiagnostics); + Assert.Empty(plan.Definitions); + Assert.Empty(plan.PreviewParts); + Assert.False(plan.HasSeparatedTail); + Assert.Contains(plan.Diagnostics, d => d.IsBlocking && + d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict); + Assert.Same(program, manual.Drawing.Program); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + } + [Fact] public void Planning_DoesNotMutateAnyLiveObjectsOrSequence() { @@ -603,7 +659,7 @@ public class AutomaticCutOffPlannerTests } [Fact] - public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail() + public void NearEquivalentExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail() { var plate = MakePlate(); plate.PartSpacing = 0; diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index bdcd75c..49c9805 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -21,7 +21,7 @@ Only real parts contribute to the occupied envelope; existing cutoff parts do no Cutoff trimming measures clearance from the part contour, excluding tagged lead-in and lead-out moves when building the closed outline. Leads must not turn a recessed outline into a convex hull and leave oversized gaps elsewhere. Genuinely open contours still use the conservative hull/bounds fallback. The conservative curve/offset approximation can leave a small extra gap (about 0.0003 in on a straight edge at the default 0.02-in clearance), not an extra quarter inch. -If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition. +If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition. For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal skeleton lines are at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. There is no line at 105 inches through the retained tail.