From 3014ab3480673186df13ac9b7fdd56d673c9ec04 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 23:17:22 -0400 Subject: [PATCH] feat(core): keep scrap cutoffs clear of clamp zones --- OpenNest.Core/AutomaticCutOffPlanner.cs | 22 ++++++++ .../CutOffs/AutomaticCutOffPlannerTests.cs | 55 +++++++++++++++++++ docs/automatic-scrap-cutoffs.md | 2 +- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index be1f594d..62beb0a3 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -29,6 +29,7 @@ public enum AutomaticCutOffDiagnosticCode LimitedCutOffConflict, EmptyCut, SegmentedCut, + ClampBlockedCut, NoSafeTailSeparator, TailBelowMinimum, } @@ -172,6 +173,22 @@ 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))) @@ -242,6 +259,11 @@ public static class AutomaticCutOffPlanner "Sheet length and width must be positive and finite.", nameof(plate)); Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate)); Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate)); + Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 || + Nonnegative(plate.EdgeSpacing.Bottom) && plate.ClampZones.All(zone => + zone?.Bounds != null && ValidBox(zone.Bounds) && + zone.Bounds.Length > 0 && zone.Bounds.Width > 0)), + "Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate)); Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) && Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings)); Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings)); diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index 73838ad9..c4b027b1 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -189,6 +189,61 @@ public class AutomaticCutOffPlannerTests Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ClampWall_SkipsNominalLineAndRefusesTailSeparator(int quadrant) + { + var plate = MakePlate(quadrant); + var sign = quadrant is 2 or 3 ? -1 : 1; + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.ClampZones.Add(new ClampZone("nominal", new Box(sign > 0 ? 35.1 : -35.3, + quadrant is 3 or 4 ? -0.625 : 0, 0.2, 0.625))); + plate.ClampZones.Add(new ClampZone("tail", new Box(sign > 0 ? 80.6 : -80.8, + quadrant is 3 or 4 ? -0.625 : 0, 0.2, 0.625))); + var before = plate.Parts.ToArray(); + var plan = Plan(plate); + Assert.Equal(new[] { sign * 70.0 }, Positions(plan)); + Assert.False(plan.HasSeparatedTail); + Assert.Equal(0, plan.TailLength); + Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.Equal(before, plate.Parts.ToArray()); + Assert.Empty(plate.CutOffs); + } + + [Fact] + public void ClampOnlyBlocksItsInflatedSpan_AndRemovedClampStopsBlocking() + { + var plate = MakePlate(); + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.ClampZones.Add(new ClampZone("front", new Box(35.25, 0, 1.5, 0.625))); + var blocked = Plan(plate); + Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(blocked)); + Assert.True(blocked.HasSeparatedTail); + plate.ClampZones.Clear(); + Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate))); + } + + [Fact] + public void ExistingLineAcrossClampCannotCertifyTail_AndPartialFootprintBlocks() + { + var plate = MakePlate(); + plate.EdgeSpacing = new Spacing(0.25, 0.25); + plate.ClampZones.Add(new ClampZone("hanging", new Box(80.6, 0, 0.2, 0.625))); + var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 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.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator); + Assert.Same(original, existing.Drawing.Program); + Assert.Same(existing, Assert.Single(plate.CutOffs)); + } + [Fact] public void Planning_DoesNotMutateAnyLiveObjectsOrSequence() { diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index 69f45a10..bdcd75cd 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. 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. 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.