diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 8694e5a..8cb64cf 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -69,6 +69,9 @@ public static class PostVerificationAnalyzer findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null, "Clamp check: " + exception.Message)); } + // One clamp warning per cut-off part and clamp: the placed-program check below + // reports only what the nominal definition check has not already reported. + var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>(); if (clamps != null) { try @@ -83,6 +86,8 @@ public static class PostVerificationAnalyzer partIndex = partSlot; break; } + if (partIndex >= 0) + cutOffClampContacts.Add((partIndex + 1, contact.ClampId)); findings.Add(new(PostVerificationKind.Overlap, plateNumber, partIndex < 0 ? null : partIndex + 1, null, $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. " @@ -156,6 +161,25 @@ public static class PostVerificationAnalyzer ClampId = zone.Id }); } + // The placed program is what posts: a definition edited without + // regenerating it can still cut through a clamp its nominal line misses. + if (cutoff && clamps is { Count: > 0 }) + foreach (var move in moves) + { + cancellationToken.ThrowIfCancellationRequested(); + if (move.Rapid || move.Start is not { } from) + continue; + foreach (var zone in clamps) + if (!cutOffClampContacts.Contains((partNumber, zone.Id)) + && CutTouches(move, from, zone.Bounds, out var contact)) + { + cutOffClampContacts.Add((partNumber, zone.Id)); + findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null, + $"The placed cut-off program crosses active clamp '{zone.Id}'. " + + "Regenerate or move the cut-off before posting; this warning is not a repair.") + { Location = contact, ClampId = zone.Id }); + } + } position = moves[^1].End; } catch (Exception exception) when (IsInvalid(exception)) @@ -190,6 +214,19 @@ public static class PostVerificationAnalyzer return new PostVerificationReport(findings); } + // Lines are tested exactly; an arc conservatively by its whole supporting extent. + private static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact) + { + if (move.Curve == null || move.Curve.IsLine) + return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact); + var extent = move.Curve.Extent; + contact = new Vector( + System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right), + System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top)); + return !(extent.MinX > zone.Right || extent.MaxX < zone.Left + || extent.MinY > zone.Top || extent.MaxY < zone.Bottom); + } + private static bool IsInvalid(Exception exception) => exception is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 5e78685..5689a51 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -147,6 +147,7 @@ internal static class PostVerificationGeometry private double Radius { get; } private double Sweep { get; } internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End); + internal bool IsLine => !Center.HasValue; // Native entities are freshly allocated; the immutable curve never exposes state. internal Entity ToEntity() => Center is { } center diff --git a/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs index 4a4a981..83e0fb7 100644 --- a/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs +++ b/OpenNest.Tests/Diagnostics/ClampZonePlacementFrameTests.cs @@ -199,6 +199,42 @@ public class ClampZonePlacementFrameTests Assert.DoesNotContain(report.Findings, f => f.ClampId != null || f.Message.Contains("clamp")); } + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void StalePlacedCutoffAcrossRailClampStillRequiresConsent(int quadrant) + { + // The placed vertical cut runs across the whole sheet width, through the clamp. + var plate = RailPlate(quadrant, 35); + var cut = new CutOff(new Vector(XSign(quadrant) * 35, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(cut); + plate.RegenerateCutOffs(new CutOffSettings { CutDirection = CutDirection.TowardOrigin }); + + // In quadrants 3 and 4 the incoming rapid also reaches the clamp; only the cut is asserted. + var current = PostVerificationAnalyzer.Analyze(NestOf(plate)); + var nominal = Assert.Single(current.Findings, f => f.Kind == PostVerificationKind.Overlap); + Assert.Equal("clamp-1", nominal.ClampId); + Assert.Equal(1, nominal.PartNumber); + + // The definition moves clear of the clamp, but the placed program is not regenerated. + cut.Position = new Vector(XSign(quadrant) * 50, 0); + Assert.Empty(ClampCutDiagnostics.Find(plate)); + + var stale = PostVerificationAnalyzer.Analyze(NestOf(plate)); + var placed = Assert.Single(stale.Findings, f => f.Kind == PostVerificationKind.Overlap); + Assert.Equal("clamp-1", placed.ClampId); + Assert.Equal(1, placed.PartNumber); + Assert.Contains("placed", placed.Message); + var footprint = WorldFootprint(quadrant, 35, 1.5, 0.625); + var location = Assert.NotNull(placed.Location); + Assert.InRange(location.X, footprint.Left, footprint.Right); + Assert.InRange(location.Y, footprint.Bottom, footprint.Top); + Assert.False(stale.CanPost(false)); + Assert.True(stale.CanPost(true)); + } + [Theory] [InlineData("NaN")] [InlineData("negative")] diff --git a/docs/post-verification.md b/docs/post-verification.md index b642fbc..7a5240c 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -57,6 +57,8 @@ shows all three check categories, even when there are no findings: a sufficiently large cutout is not itself an overlap. The same review also warns if a nominal manual cut-off line crosses an active clamp, even when its regenerated cut is currently trimmed; a line contact has no material-overlap area. Removed clamps on that sheet are not checked. + The placed cut-off program is checked too, so a definition edited without regenerating its + cut still warns where the posted cut would cross a clamp (one warning per cut-off and clamp). Clamp zones are compared in the plate's quadrant frame. A missing or malformed clamp zone makes the clamp checks incomplete (a warning), never a clear result. This uses clean drawing geometry, not added lead-in/out paths.