From d7187f65c70bbff8099cdf18d73c786abd0d4ee1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 23:27:43 -0400 Subject: [PATCH] feat(diagnostics): warn on cutoffs and rapids crossing clamps --- .../Diagnostics/ClampCutDiagnostics.cs | 81 +++++++++++++++++++ .../Diagnostics/OverlapGeometryStamp.cs | 24 +++++- .../Diagnostics/OverlapReportState.cs | 15 +++- .../Diagnostics/PlateOverlapAnalyzer.cs | 20 ++++- .../Diagnostics/PlateOverlapReport.cs | 10 ++- .../Diagnostics/PostVerificationAnalyzer.cs | 52 +++++++++++- .../Diagnostics/PostVerificationReport.cs | 2 + .../Diagnostics/ClampCutDiagnosticsTests.cs | 54 +++++++++++++ .../PlateOverlapAnalyzerClampZoneTests.cs | 22 +++++ .../PostVerificationAnalyzerTests.cs | 47 +++++++++++ OpenNest/Controls/OverlapOverlayController.cs | 31 ++++++- OpenNest/Forms/CuttingInspectionPanel.cs | 4 + docs/geometry/visual-overlap-check.md | 12 ++- docs/post-verification.md | 23 +++--- 14 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs create mode 100644 OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs diff --git a/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs new file mode 100644 index 0000000..3076928 --- /dev/null +++ b/OpenNest.Core/Diagnostics/ClampCutDiagnostics.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Diagnostics; + +/// A nominal manual cut-off line meeting an active, physical clamp footprint. +public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location); + +/// +/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked +/// even if their current regenerated program is stale or trimmed by nearby parts. +/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported. +/// +public static class ClampCutDiagnostics +{ + public static IReadOnlyList Find(Plate plate) + { + ArgumentNullException.ThrowIfNull(plate); + var findings = new List(); + if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0) + return findings.AsReadOnly(); + var sheet = plate.BoundingBox(false); + for (var index = 0; index < plate.CutOffs.Count; index++) + { + var cut = plate.CutOffs[index]; + if (cut == null || !Enum.IsDefined(cut.Axis)) + throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate)); + var start = cut.Axis == CutOffAxis.Vertical + ? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom) + : new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y); + var end = cut.Axis == CutOffAxis.Vertical + ? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top) + : new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y); + foreach (var zone in plate.ClampZones) + if (Intersects(start, end, zone.Bounds, out var location)) + findings.Add(new CutOffClampFinding(index, zone.Id, location)); + } + return findings.AsReadOnly(); + } + + /// True for contact with the whole rectangle, including a boundary touch. + public static bool Intersects(Vector from, Vector to, Box box, out Vector location) + { + location = default; + if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location) + || !double.IsFinite(box.Right) || !double.IsFinite(box.Top) + || box.Length <= 0 || box.Width <= 0) + throw new ArgumentException("Clamp contact geometry must be finite and nonempty."); + var dx = to.X - from.X; + var dy = to.Y - from.Y; + if (!double.IsFinite(dx) || !double.IsFinite(dy) + || !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X) + || !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y)) + throw new ArgumentException("Clamp contact motion is too large."); + var enter = 0.0; + var exit = 1.0; + if (!Clip(-dx, from.X - box.Left, ref enter, ref exit) + || !Clip(dx, box.Right - from.X, ref enter, ref exit) + || !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit) + || !Clip(dy, box.Top - from.Y, ref enter, ref exit)) + return false; + var midpoint = (enter + exit) * 0.5; + location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint); + return true; + } + + private static bool Clip(double p, double q, ref double enter, ref double exit) + { + if (p == 0) + return q >= 0; + var ratio = q / p; + if (p < 0) + enter = System.Math.Max(enter, ratio); + else + exit = System.Math.Min(exit, ratio); + return enter <= exit; + } + + private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y); +} diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs index b0b1a1d..2146ff4 100644 --- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -12,10 +12,13 @@ public sealed class OverlapGeometryStamp private readonly Plate plate; private readonly Entry[] entries; private readonly (string Id, long X, long Y, long Length, long Width)[] zones; + private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs; + private readonly (long Width, long Length, int Quadrant) sheet; private OverlapGeometryStamp(Plate plate) { this.plate = plate; + sheet = (Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant); entries = new Entry[plate.Parts.Count]; for (var i = 0; i < entries.Length; i++) entries[i] = new Entry(plate.Parts[i]); @@ -26,8 +29,16 @@ public sealed class OverlapGeometryStamp var box = zone.Bounds; zones[i] = (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width)); } + cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count]; + for (var i = 0; i < cutoffs.Length; i++) + cutoffs[i] = CutOffState(plate.CutOffs[i]); } + private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) => + (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y), + cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null, + cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null); + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); public static OverlapGeometryStamp Capture(Plate plate) => new(plate); @@ -46,9 +57,20 @@ public sealed class OverlapGeometryStamp return true; } + internal bool SameCutOffs(Plate current) + { + if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length + || sheet != (Bits(current.Size.Width), Bits(current.Size.Length), current.Quadrant)) + return false; + for (var i = 0; i < cutoffs.Length; i++) + if (cutoffs[i] != CutOffState(current.CutOffs[i])) + return false; + return true; + } + public bool Matches(Plate current) { - if (current.Parts.Count != entries.Length || !SameZones(current)) + if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current)) return false; for (var i = 0; i < entries.Length; i++) if (!entries[i].Matches(current.Parts[i])) diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index b88d5e5..c5f99a1 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -21,17 +21,22 @@ public sealed class OverlapReportState public PlateOverlapReport Report { get; private set; } /// Known overlap pairs safe to draw, even while the full layout needs a recheck. public IReadOnlyList DisplayPairs { get; private set; } = Array.Empty(); + public IReadOnlyList DisplayCutOffClampFindings { get; private set; } = + Array.Empty(); public bool IsRunning => Status == OverlapCheckStatus.Checking; public string Message => Status switch { OverlapCheckStatus.Checking => "Checking overlaps…", - OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 + OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0 ? (Report.Pairs.Count == 0 ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs") - : $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps", + : $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, " + + $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts", OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; " + (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ") + + (Report.CutOffClampFindings.Count == 0 ? "" + : $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ") + $"{uncheckedPartCount} parts could not be checked", OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again", OverlapCheckStatus.Canceled => "Overlap check canceled", @@ -60,6 +65,7 @@ public sealed class OverlapReportState Report = report; DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs : report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly(); + DisplayCutOffClampFindings = report.CutOffClampFindings; displayStamp = stamp; observedDisplayStamp = stamp; uncheckedPartCount = CountUncheckedParts(report.Issues); @@ -111,11 +117,13 @@ public sealed class OverlapReportState return; var unchanged = displayStamp.UnchangedSlots(plate); var sameZones = displayStamp.SameZones(plate); + if (!sameZones || !displayStamp.SameCutOffs(plate)) + DisplayCutOffClampFindings = Array.Empty(); var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList(); if (retained.Count != DisplayPairs.Count) DisplayPairs = retained.AsReadOnly(); - if (DisplayPairs.Count == 0) + if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0) ClearDisplayPairs(); else observedDisplayStamp = OverlapGeometryStamp.Capture(plate); @@ -124,6 +132,7 @@ public sealed class OverlapReportState private void ClearDisplayPairs() { DisplayPairs = Array.Empty(); + DisplayCutOffClampFindings = Array.Empty(); displayStamp = null; observedDisplayStamp = null; } diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index a5453ac..7a0aa04 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -17,6 +17,23 @@ public static class PlateOverlapAnalyzer { public const double ChordTolerance = 0.001; + /// + /// Capture a whole plate including active clamps and nominal cut-off definitions. + /// Use this entry for the desktop overlap check so manual cut-off contacts + /// cannot be mistaken for a clear plate merely because cut-offs are not material. + /// + public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plate); + var material = Capture(plate.Parts.ToArray(), plate.ClampZones.ToArray(), + cache ?? new OverlapMaterialCache(), cancellationToken); + var cutoffs = ClampCutDiagnostics.Find(plate); + cancellationToken.ThrowIfCancellationRequested(); + return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(), + material.Clamps.ToList(), cutoffs.ToList()); + } + /// /// Captures poses and converts each distinct clean source program to owned entities once. /// Inputs must not change during capture. Later analysis never reads live domain objects. @@ -226,7 +243,8 @@ public static class PlateOverlapAnalyzer return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId) .ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId) .ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings - .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList()); + .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(), + snapshot.CutOffClampFindings.ToList()); } /// diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 436773b..a5fd5b4 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -8,12 +8,14 @@ namespace OpenNest.Diagnostics; public sealed class PlateOverlapReport { internal PlateOverlapReport(List pairs, List issues, - PlateOverlapSnapshot snapshot, List clampFindings = null) + PlateOverlapSnapshot snapshot, List clampFindings = null, + List cutOffClampFindings = null) { Pairs = pairs.AsReadOnly(); Issues = issues.AsReadOnly(); Snapshot = snapshot; ClampFindings = (clampFindings ?? new List()).AsReadOnly(); + CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); } /// The owned input this report was computed from; the baseline for incremental rechecks. @@ -28,6 +30,8 @@ public sealed class PlateOverlapReport /// are reported like other overlap findings; they do not change . /// public IReadOnlyList ClampFindings { get; } + /// Nominal cut-off/clamp contacts. Lines have no material area or part pair. + public IReadOnlyList CutOffClampFindings { get; } public bool IsComplete => Issues.Count == 0; public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; @@ -112,11 +116,12 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message public sealed class PlateOverlapSnapshot { internal PlateOverlapSnapshot(List parts, List issues, - List clamps = null) + List clamps = null, List cutOffClampFindings = null) { Parts = parts.AsReadOnly(); Issues = issues.AsReadOnly(); Clamps = (clamps ?? new List()).AsReadOnly(); + CutOffClampFindings = (cutOffClampFindings ?? new List()).AsReadOnly(); } internal IReadOnlyList Parts { get; } @@ -124,6 +129,7 @@ public sealed class PlateOverlapSnapshot /// Owned clamp keep-out rectangles in plate coordinates, capture order. internal IReadOnlyList Clamps { get; } + internal IReadOnlyList CutOffClampFindings { get; } } internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 4110b56..8aa8b44 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -56,6 +56,30 @@ public static class PostVerificationAnalyzer } var materialParts = new List(); var indices = new List(); + try + { + foreach (var contact in ClampCutDiagnostics.Find(plate)) + { + var cut = plate.CutOffs[contact.CutOffIndex]; + var partIndex = -1; + for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++) + if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing)) + { + partIndex = partSlot; + break; + } + findings.Add(new(PostVerificationKind.Overlap, plateNumber, + partIndex < 0 ? null : partIndex + 1, null, + $"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. " + + "Review the clamp and cut before posting; this warning is not a repair.") + { Location = contact.Location, ClampId = contact.ClampId }); + } + } + catch (Exception exception) when (IsInvalid(exception)) + { + findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null, + "Clamp/cut-off check: " + exception.Message)); + } var obstacles = new ReleasedContourState(); Vector? position = startPoint; for (var index = 0; index < plate.Parts.Count; index++) @@ -95,6 +119,27 @@ public static class PostVerificationAnalyzer && move.Curve.Length > PostVerificationGeometry.Epsilon)) Incomplete("Placed program has no cutting contour motions for this drawing."); obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken); + if (plate.ClampZones.Count != 0) + foreach (var move in moves) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!move.Rapid || move.Start is not { } from + || from.X == move.End.X && from.Y == move.End.Y) + continue; + foreach (var zone in plate.ClampZones) + if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact)) + findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber, + partNumber, null, + $"Direct XY rapid crosses active clamp '{zone.Id}'. " + + "Head height is not modeled; review the posted route and clamp setup.") + { + Location = (from + move.End) * 0.5, + RapidStart = from, + RapidEnd = move.End, + ContactPoints = new[] { contact }, + ClampId = zone.Id + }); + } position = moves[^1].End; } catch (Exception exception) when (IsInvalid(exception)) @@ -110,11 +155,16 @@ public static class PostVerificationAnalyzer } cancellationToken.ThrowIfCancellationRequested(); var overlap = PlateOverlapAnalyzer.Analyze( - PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken); + PlateOverlapAnalyzer.Capture(materialParts, plate.ClampZones.ToArray(), cancellationToken), + cancellationToken); foreach (var pair in overlap.Pairs) findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).") { Location = pair.Centroid, Overlap = pair }); + foreach (var pair in overlap.ClampFindings) + findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], + null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).") + { Location = pair.Centroid, Overlap = pair }); foreach (var issue in overlap.Issues) findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId], issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs index b399f83..8b1e6b3 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs @@ -24,6 +24,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat /// Rapid boundary contacts in travel order; collinear contact spans use their endpoints. public IReadOnlyList ContactPoints { get; init; } = Array.Empty(); public PlateOverlapPair Overlap { get; init; } + /// Active clamp contacted by a cut-off or direct XY rapid, if applicable. + public string ClampId { get; init; } } /// Owned, immutable findings. Consent is evaluated afresh, never stored. diff --git a/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs new file mode 100644 index 0000000..59a59ee --- /dev/null +++ b/OpenNest.Tests/Diagnostics/ClampCutDiagnosticsTests.cs @@ -0,0 +1,54 @@ +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Diagnostics; + +public class ClampCutDiagnosticsTests +{ + [Theory] + [InlineData(CutOffAxis.Vertical, 10.5, 0, 1, true)] + [InlineData(CutOffAxis.Vertical, 10.5, 2, 4, false)] + [InlineData(CutOffAxis.Vertical, 14, 0, 2, false)] + [InlineData(CutOffAxis.Horizontal, 0.25, 9, 13, true)] + [InlineData(CutOffAxis.Horizontal, 2, 9, 13, false)] + [InlineData(CutOffAxis.Horizontal, 0.25, 12, 13, false)] + public void NominalLimitsAndBothAxesDetermineContact(CutOffAxis axis, double coordinate, + double start, double end, bool crosses) + { + var plate = new Plate(24, 48); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(axis == CutOffAxis.Vertical + ? new Vector(coordinate, 0) : new Vector(0, coordinate), axis) + { StartLimit = start, EndLimit = end }); + var findings = ClampCutDiagnostics.Find(plate); + Assert.Equal(crosses, findings.Count != 0); + if (crosses) + Assert.Equal("front", Assert.Single(findings).ClampId); + } + + [Fact] + public void SegmentChecksPhysicalFootprintNotInfiniteLineOrPartSpacing() + { + var zone = new Box(10, 0, 1.5, 0.625); + Assert.True(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(12, 0.3), + zone, out var contact)); + Assert.InRange(contact.X, 10, 11.5); + Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 3), new Vector(12, 3), + zone, out _)); + Assert.False(ClampCutDiagnostics.Intersects(new Vector(9, 0.3), new Vector(9.9, 0.3), + zone, out _)); + Assert.Throws(() => ClampCutDiagnostics.Intersects( + new Vector(double.NaN, 0), new Vector(12, 0), zone, out _)); + } + + [Fact] + public void OnlyActiveClampsAreReported() + { + var plate = new Plate(24, 48); + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical)); + Assert.Single(ClampCutDiagnostics.Find(plate)); + plate.ClampZones.Clear(); // Removed on this sheet; no longer an obstacle. + Assert.Empty(ClampCutDiagnostics.Find(plate)); + } +} diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs index ac1ba12..8d5171d 100644 --- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs @@ -31,6 +31,28 @@ public class PlateOverlapAnalyzerClampZoneTests private static readonly ClampZone Front1 = new("front-1", new Box(10, 0, 1.5, 0.625)); private static readonly ClampZone Front2 = new("front-2", new Box(20, 0, 1.5, 0.625)); + [Fact] + public void ManualCutoffCrossingClampAppearsInOverlapCheckWithoutBeingMaterial() + { + var plate = new Plate(new Size(24, 48)); + plate.ClampZones.Add(Front1); + plate.CutOffs.Add(new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical)); + var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate)); + Assert.Empty(report.Pairs); + Assert.Empty(report.ClampFindings); + Assert.True(report.IsComplete); + var finding = Assert.Single(report.CutOffClampFindings); + Assert.Equal("front-1", finding.ClampId); + Assert.Equal(0, finding.CutOffIndex); + Assert.InRange(finding.Location.X, 10, 11.5); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, report)); + Assert.Contains("cut-off", state.Message); + plate.CutOffs.Clear(); + Assert.False(state.EnsureFresh(plate)); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + } + [Fact] public void PartOverlappingClampProducesHoleAwareFinding() { diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs index 855e09d..f95d15e 100644 --- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -98,6 +98,53 @@ public class PostVerificationAnalyzerTests Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn)); } + [Fact] + public void ManualCutoffAndRapidCrossingClampRequireFreshPostingConsent() + { + var nest = NestWith(Rectangle(50, 20, 4)); + var plate = Assert.Single(nest.Plates); + plate.ClampZones.Add(new ClampZone("front-3", new Box(10, 0, 1.5, 0.625))); + var cutoff = new CutOff(new Vector(10.5, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(cutoff); + plate.RegenerateCutOffs(new CutOffSettings()); + var report = PostVerificationAnalyzer.Analyze(nest); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Overlap + && f.Message.Contains("front-3") && f.Message.Contains("cut-off") && f.PlateNumber == 1); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing + && f.Message.Contains("front-3") && f.RapidStart.HasValue && f.RapidEnd.HasValue); + Assert.False(report.CanPost(false)); + Assert.True(report.CanPost(true)); + Assert.False(PostVerificationAnalyzer.Analyze(nest).CanPost(false)); + plate.ClampZones.Clear(); + var withoutClamps = PostVerificationAnalyzer.Analyze(nest); + Assert.DoesNotContain(withoutClamps.Findings, f => f.Message.Contains("front-3")); + } + + [Fact] + public void InvalidManualCutoffGeometryIsIncompleteNotClear() + { + var nest = NestWith(Rectangle(40, 20, 4)); + var plate = nest.Plates[0]; + plate.ClampZones.Add(new ClampZone("front", new Box(10, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(double.NaN, 0), CutOffAxis.Vertical)); + var report = PostVerificationAnalyzer.Analyze(nest); + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete + && f.Message.Contains("Clamp/cut-off check")); + Assert.False(report.CanPost(false)); + } + + [Fact] + public void PartMaterialOnClampAppearsInPrePostOverlapFindings() + { + var nest = NestWith(Rectangle(10, 0, 4)); + nest.Plates[0].ClampZones.Add(new ClampZone("front-1", new Box(10, 0, 1.5, 0.625))); + var finding = Assert.Single(PostVerificationAnalyzer.Analyze(nest).Findings.Where(f => + f.Kind == PostVerificationKind.Overlap && f.Message.Contains("front-1"))); + Assert.Equal(1, finding.PartNumber); + Assert.Null(finding.OtherPartNumber); + Assert.NotNull(finding.Overlap); + } + [Fact] public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks() { diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index b367c76..d7e01be 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -148,8 +148,7 @@ internal sealed class OverlapOverlayController : IDisposable try { // Do not include PreviewManager parts. Capture owns clean geometry and stable names. - var snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), - plate.ClampZones.ToArray(), materialCache, token); + var snapshot = PlateOverlapAnalyzer.CapturePlate(plate, materialCache, token); var previous = baseline; var analyze = Analyze ?? ((input, cancel) => PlateOverlapAnalyzer.Analyze(input, previous, cancel)); var report = await Task.Run(() => analyze(snapshot, token), token); @@ -381,6 +380,8 @@ internal sealed class OverlapOverlayController : IDisposable if (ShowsCentroids) DrawCentroids(graphics); } + if (state.DisplayMode != OverlapDisplayMode.Off) + DrawCutOffClampContacts(graphics); DrawStateLabel(graphics); } @@ -598,6 +599,32 @@ internal sealed class OverlapOverlayController : IDisposable } } + private void DrawCutOffClampContacts(Graphics graphics) + { + var dpi = view.DeviceDpi / 96f; + using var halo = new Pen(Color.White, 4 * dpi); + using var warning = new Pen(Color.DarkRed, 2 * dpi); + foreach (var finding in state.DisplayCutOffClampFindings) + { + var point = view.PointWorldToGraph(finding.Location); + if (!float.IsFinite(point.X) || !float.IsFinite(point.Y)) + continue; + foreach (var pen in new[] { halo, warning }) + { + graphics.DrawLine(pen, point.X - 8 * dpi, point.Y - 8 * dpi, + point.X + 8 * dpi, point.Y + 8 * dpi); + graphics.DrawLine(pen, point.X - 8 * dpi, point.Y + 8 * dpi, + point.X + 8 * dpi, point.Y - 8 * dpi); + } + var label = $"Cut-off {finding.CutOffIndex + 1} / {finding.ClampId}"; + var size = graphics.MeasureString(label, view.Font); + var x = point.X + 10 * dpi; + var y = point.Y - size.Height; + graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height); + graphics.DrawString(label, view.Font, Brushes.DarkRed, x, y); + } + } + private void DrawStateLabel(Graphics graphics) { var saved = graphics.Save(); diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs index 03a8da5..4f9ccb2 100644 --- a/OpenNest/Forms/CuttingInspectionPanel.cs +++ b/OpenNest/Forms/CuttingInspectionPanel.cs @@ -302,7 +302,11 @@ internal sealed class CuttingInspectionPanel : UserControl view.ShowValidationFinding(finding with { Location = location }); var hint = finding.Kind switch { + PostVerificationKind.Overlap when finding.ClampId != null => + "The ring marks a nominal cut-off contacting a clamp; no material-overlap area is implied.", PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.", + PostVerificationKind.RapidCrossing when finding.ClampId != null => + "The crosshair marks the rapid's contact with the clamp. Head height is not modeled.", PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0 ? "Crosshairs mark contacts with the completed contour. The pulsing dashed orange line shows the rapid." : "The pulsing dashed orange line shows the rapid inside the completed contour; there is no boundary contact point to mark.", diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index f4b1f2a..a56715b 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -98,8 +98,8 @@ vertices cannot mutate the snapshot, report, or live geometry. ## Capture once, analyze off-thread ```csharp -// UI thread, while parts/drawings are stable: -var snapshot = PlateOverlapAnalyzer.Capture(parts, plate.ClampZones.ToArray(), cancellationToken); +// UI thread, while the plate and drawings are stable: +var snapshot = PlateOverlapAnalyzer.CapturePlate(plate, cancellationToken: cancellationToken); // Worker thread, no access to live Part/Drawing/Program objects: var report = await Task.Run( @@ -159,6 +159,14 @@ triangulation, or `Collision.Check` call is not internally interruptible. A report with any issue has `IsComplete == false`, even if `Pairs` is empty. - No part, drawing, quantity, pose, cutting state, or selection is modified. +For a full plate, `PlateOverlapAnalyzer.CapturePlate(plate)` also captures active +clamps and nominal manual cut-off definitions. `CutOffClampFindings` is a separate +list of zero-area line/rectangle contacts, not `Pairs` or material overlap. The +view marks these with an X and a cut-off number/clamp ID, including contacts whose +current regenerated program has been trimmed. Removed clamps produce no contact. +Editing definitions, sheet size/quadrant or zones stales the result; posting runs +its own fresh check with per-attempt consent. + ## Interpretation and limits Regions are the kernel's convex, hole-subtracted fragments, not merged connected diff --git a/docs/post-verification.md b/docs/post-verification.md index 9d3f4cc..841eae6 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -52,18 +52,23 @@ The desktop's **Nest > Post > (post processor)** workflow checks every plate aft post settings are accepted and before asking where to save CNC output. The review shows all three check categories, even when there are no findings: -- **Overlapping parts:** shared material between placed parts, including containment. - Holes are subtracted; placing a part inside a sufficiently large cutout is not - itself an overlap. This uses clean drawing geometry, not added lead-in/out paths. +- **Overlapping parts and clamps:** shared material between placed parts, including containment, + and physical material overlapping an active clamp. Holes are subtracted; placing a part inside + 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. + This uses clean drawing geometry, not added lead-in/out paths. - **Missing lead-ins:** cutting contours without an actual, nonzero lead-in in the placed program. Applying a lead-in to only one contour does not clear the other contours. Scribe-only work and scrap cutoff lines do not require lead-ins here. The cutting planner supports direct contour piercing without lead-ins; that does not suppress this pre-post review warning or grant posting consent. - **Rapid crossings:** direct XY moves crossing closed, already-cut, untabbed - contours in cutting order. This includes earlier holes within the same part and - previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are - respected; enabling tabs in settings alone is not evidence that a gap exists. + contours in cutting order, or an active clamp's physical footprint. This includes + earlier holes within the same part and previously cut parts. A future cut is not + an obstacle yet. Actual uncut gaps are respected; enabling tabs in settings alone + is not evidence that a gap exists. Clamp-rapid warnings are conservative: head + height/retracts are not modeled, so inspect the emitted route and machine setup. Findings identify the plate and part's position in the cutting sequence. Read the scrollable report, cancel to fix the nest, then post again to rerun verification. @@ -120,9 +125,9 @@ change does not silently alter their existing CNC output order. These checks are warnings, not a machine safety certification. They inspect the nest's placed programs and direct XY travel, not a simulation of final NC output. A post processor or controller may change routing, cutting order, lead-ins, -retract height, or parking moves. The checks do not model tipping, slats, clamps, -actual tab strength, kerf, or safe Z height. Review the generated CNC program and -machine setup before cutting, even when this report has no warnings. +retract height, or parking moves. They check planar clamp footprints but not clamp +height, actual head clearance, tipping, slats or tab strength. Review the generated +CNC program and machine setup before cutting, even when this report has no warnings. `OpenNest.Core/Diagnostics/PostVerificationAnalyzer` owns the cross-platform logic; its report exposes findings, display text, and `CanPost(risksAcknowledged)`. Use