From d59c03f5ab1f92f0a02938567fbd4853bb146d31 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 15:11:41 -0400 Subject: [PATCH] Highlight validation contacts and mute unrelated parts --- .../CuttingPlanning/ReleasedContourState.cs | 7 ++- .../Diagnostics/PostVerificationGeometry.cs | 45 ++++++++++++++-- .../Diagnostics/PostVerificationReport.cs | 2 + .../PostVerificationAnalyzerTests.cs | 1 + .../Diagnostics/RapidContactPointTests.cs | 52 +++++++++++++++++++ .../Forms/ValidateNestFormTests.cs | 28 ++++++++++ .../LayoutPartColorTests.cs | 42 +++++++++++++++ OpenNest/Controls/PlateRenderer.cs | 22 ++++---- OpenNest/Controls/ValidationOverlay.cs | 12 +++++ OpenNest/Forms/ValidateNestForm.cs | 6 ++- OpenNest/LayoutPart.cs | 24 +++++---- docs/post-verification.md | 12 ++++- 12 files changed, 223 insertions(+), 30 deletions(-) create mode 100644 OpenNest.Tests/Diagnostics/RapidContactPointTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 0999143..1c4400d 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -69,7 +69,12 @@ public sealed class ReleasedContourState findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + $"of part {obstacle.Part}.") - { Location = (start + move.End) * 0.5, RapidStart = start, RapidEnd = move.End }); + { + Location = (start + move.End) * 0.5, + RapidStart = start, + RapidEnd = move.End, + ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token) + }); } } else if (move.Layer == LayerType.Leadin) diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 606400f..db29e74 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -89,6 +89,30 @@ internal static class PostVerificationGeometry return inside; } + internal static IReadOnlyList ContactPoints(Vector start, Vector end, + IReadOnlyList curves, CancellationToken token) + { + var length = start.DistanceTo(end); + if (length <= Epsilon) + return Array.Empty(); + var direction = (end - start) * (1 / length); + var distances = new List(); + foreach (var curve in curves) + { + token.ThrowIfCancellationRequested(); + curve.ContactAfterStart(start, direction, length, distances); + } + distances.Sort(); + var points = new List(); + foreach (var distance in distances) + { + var point = start + direction * distance; + if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon) + points.Add(point); + } + return points.AsReadOnly(); + } + private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y; private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X; private static double Normalize(double angle) @@ -286,7 +310,8 @@ internal static class PostVerificationGeometry // The largest distance from the centre that ContactAfterStart can count as contact. private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack); - internal bool ContactAfterStart(Vector origin, Vector direction, double length) + internal bool ContactAfterStart(Vector origin, Vector direction, double length, + List contacts = null) { if (Center is { } center) { @@ -299,10 +324,13 @@ internal static class PostVerificationGeometry if (square < -ContactSlack) return false; var offset = System.Math.Sqrt(System.Math.Max(0, square)); - return Hit(projection - offset) || Hit(projection + offset); + return contacts == null + ? Hit(projection - offset) || Hit(projection + offset) + : Hit(projection - offset) | Hit(projection + offset); bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon - && OnArc(origin + direction * System.Math.Clamp(distance, 0, length)); + && OnArc(origin + direction * System.Math.Clamp(distance, 0, length)) + && Record(distance); } var edge = End - Start; var relativeStart = Start - origin; @@ -315,13 +343,20 @@ internal static class PostVerificationGeometry var b = Dot(End - origin, direction); var low = System.Math.Max(0, System.Math.Min(a, b)); var high = System.Math.Min(length, System.Math.Max(a, b)); - return high > Epsilon && low <= high + Epsilon; + return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high); } var distanceAlongRapid = Cross(relativeStart, edge) / denominator; var fractionAlongEdge = Cross(relativeStart, direction) / denominator; return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon && fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon) - && fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon); + && fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon) + && Record(distanceAlongRapid); + + bool Record(double distance) + { + contacts?.Add(System.Math.Clamp(distance, 0, length)); + return true; + } } internal bool CrossesRay(Vector point) diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs index af84d94..1d3b1a2 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs @@ -21,6 +21,8 @@ public sealed record PostVerificationFinding(PostVerificationKind Kind, int Plat public Vector? Location { get; init; } public Vector? RapidStart { get; init; } public Vector? RapidEnd { get; init; } + /// Rapid boundary contacts in travel order; collinear contact spans use their endpoints. + public IReadOnlyList ContactPoints { get; init; } = Array.Empty(); public PlateOverlapPair Overlap { get; init; } } diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs index 1906599..975f357 100644 --- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -113,6 +113,7 @@ public class PostVerificationAnalyzerTests f.PartNumber == 3 && f.OtherPartNumber == 1)); Assert.NotNull(crossing.RapidStart); Assert.NotNull(crossing.RapidEnd); + Assert.Equal(new[] { new Vector(0, 2), new Vector(4, 2) }, crossing.ContactPoints); Assert.Equal((crossing.RapidStart.Value + crossing.RapidEnd.Value) * 0.5, crossing.Location); } diff --git a/OpenNest.Tests/Diagnostics/RapidContactPointTests.cs b/OpenNest.Tests/Diagnostics/RapidContactPointTests.cs new file mode 100644 index 0000000..d64b6fa --- /dev/null +++ b/OpenNest.Tests/Diagnostics/RapidContactPointTests.cs @@ -0,0 +1,52 @@ +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Diagnostics; + +public class RapidContactPointTests +{ + [Theory] + [InlineData(-2, 2, 6, 2, 2)] + [InlineData(-2, -2, 6, 6, 2)] // Shared corners are deduplicated. + [InlineData(-2, 4, 6, 4, 2)] // Collinear contact span. + [InlineData(-2, 2, 0, 2, 1)] // Endpoint arrival. + [InlineData(0, 0, -2, -2, 0)] // Start-only departure. + [InlineData(1, 1, 2, 2, 0)] // Entirely inside. + public void RectangleContacts(double x1, double y1, double x2, double y2, int count) + { + var vertices = new[] { new Vector(0, 0), new Vector(4, 0), new Vector(4, 4), new Vector(0, 4) }; + var curves = Enumerable.Range(0, 4).Select(i => + PostVerificationGeometry.Curve.Create(vertices[i], vertices[(i + 1) % 4], null, false)).ToArray(); + var start = new Vector(x1, y1); + var end = new Vector(x2, y2); + var points = PostVerificationGeometry.ContactPoints(start, end, curves, default); + Assert.Equal(count, points.Count); + Assert.True(((ICollection)points).IsReadOnly); + Assert.All(points, point => + { + Assert.Contains(curves, curve => curve.Contains(point)); + Assert.True(new Line(start, end).ClosestPointTo(point).DistanceTo(point) < 1e-8); + }); + Assert.Equal(points.OrderBy(point => point.DistanceTo(start)), points); + } + + [Theory] + [InlineData(0, 2)] + [InlineData(1, 1)] // Tangency has a single marker. + public void CircleContactsUseNativeCurve(double y, int count) + { + var circle = PostVerificationGeometry.Curve.Create(new Vector(1, 0), new Vector(1, 0), Vector.Zero, false); + var points = PostVerificationGeometry.ContactPoints(new Vector(-2, y), new Vector(2, y), [circle], default); + Assert.Equal(count, points.Count); + Assert.All(points, point => Assert.Equal(1, point.DistanceTo(Vector.Zero), 8)); + Assert.Equal(new Vector(count == 1 ? 0 : -1, y), points[0]); + } + + [Fact] + public void ArcContactsExcludeUnusedCircleHalf() + { + var arc = PostVerificationGeometry.Curve.Create(new Vector(0, -1), new Vector(0, 1), Vector.Zero, false); + var points = PostVerificationGeometry.ContactPoints(new Vector(-2, 0), new Vector(2, 0), [arc], default); + Assert.Equal(new Vector(1, 0), Assert.Single(points)); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs b/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs index d0e063e..f452a0e 100644 --- a/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs @@ -11,6 +11,34 @@ namespace OpenNest.WinForms.Tests.Forms; public class ValidateNestFormTests { + [Fact] + public void RapidContactCrosshairIsDrawnAtContactAndClearedWithFinding() + { + RunSta(() => + { + using var view = new PlateView(); + view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.RapidCrossing, + 1, 2, 1, "crossing") + { + RapidStart = new Vector(-1, 0), + RapidEnd = new Vector(1, 0), + ContactPoints = Array.AsReadOnly(new[] { Vector.Zero }) + }); + using var bitmap = new Bitmap(120, 120); + using var graphics = Graphics.FromImage(bitmap); + graphics.TranslateTransform(60, 60); + graphics.Clear(Color.Black); + view.ValidationOverlay.Draw(graphics); + // Off the horizontal rapid, on the contact's vertical crosshair. + var pixel = bitmap.GetPixel(60, 65); + Assert.True(pixel.R > 150 && pixel.B > 50 && pixel.G < 100); + view.ClearValidationFinding(); + graphics.Clear(Color.Black); + view.ValidationOverlay.Draw(graphics); + Assert.Equal(Color.Black.ToArgb(), bitmap.GetPixel(60, 65).ToArgb()); + }); + } + #pragma warning disable xUnit1031 // Completed tasks on a dedicated STA thread. [Fact] public void GridNavigatesMainViewAndRestoresViewportOnDisposal() diff --git a/OpenNest.WinForms.Tests/LayoutPartColorTests.cs b/OpenNest.WinForms.Tests/LayoutPartColorTests.cs index e4dd65b..5fc1c28 100644 --- a/OpenNest.WinForms.Tests/LayoutPartColorTests.cs +++ b/OpenNest.WinForms.Tests/LayoutPartColorTests.cs @@ -1,11 +1,53 @@ using System.Drawing; using System.Drawing.Drawing2D; using OpenNest.Controls; +using OpenNest.Diagnostics; +using OpenNest.Geometry; namespace OpenNest.WinForms.Tests; public class LayoutPartColorTests { + [Fact] + public void ValidationMutesOnlyUnrelatedInstancesAndRestoresColors() + => StaTestThread.Run(() => + { + var program = new CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(40, 0); + program.LineTo(40, 40); + program.LineTo(0, 40); + program.LineTo(0, 0); + var drawing = new Drawing("shared drawing", program) { Color = Color.Coral }; + using var view = new PlateView { Width = 240, Height = 100, DrawBounds = false }; + view.Matrix = new Matrix(); + for (var i = 0; i < 3; i++) + view.Plate.Parts.Add(new Part(drawing) { Location = new Vector(i * 60, 0) }); + using var image = new Bitmap(240, 100); + using var graphics = Graphics.FromImage(image); + view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.Overlap, 1, 1, 2, "overlap")); + view.LayoutParts[2].IsSelected = true; + view.Renderer.DrawParts(graphics); + Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(10, 10).ToArgb()); + Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(70, 10).ToArgb()); + Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(130, 10).ToArgb()); + Assert.True(view.LayoutParts[2].IsSelected); + view.LayoutParts[2].IsSelected = false; + + view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.MissingLeadIn, 1, 3, null, "lead")); + view.Renderer.DrawParts(graphics); + Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(10, 10).ToArgb()); + Assert.Equal(Color.Gainsboro.ToArgb(), image.GetPixel(70, 10).ToArgb()); + Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(130, 10).ToArgb()); + + view.ClearValidationFinding(); + view.Renderer.DrawParts(graphics); + for (var i = 0; i < 3; i++) + Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(i * 60 + 10, 10).ToArgb()); + Assert.Equal(Color.Coral, drawing.Color); + Assert.All(view.LayoutParts, part => Assert.Equal(Color.Coral, part.Color)); + }, TimeSpan.FromMinutes(1), "Validation color test timed out."); + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index 8b9180b..f0b7601 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -121,10 +121,12 @@ namespace OpenNest.Controls if (!part.DisplayBounds.IntersectsWith(viewBounds)) continue; - part.Draw(g, (i + 1).ToString()); - DrawBendLines(g, part.BasePart); - DrawEtchMarks(g, part.BasePart); - DrawGrainWarning(g, part.BasePart); + var finding = view.ValidationOverlay.Finding; + var muted = finding != null && finding.PartNumber != i + 1 && finding.OtherPartNumber != i + 1; + part.Draw(g, (i + 1).ToString(), muted); + DrawBendLines(g, part.BasePart, muted); + DrawEtchMarks(g, part.BasePart, muted); + DrawGrainWarning(g, part.BasePart, muted); } var previewParts = view.PreviewParts; @@ -264,7 +266,7 @@ namespace OpenNest.Controls } } - private void DrawBendLines(Graphics g, Part part) + private void DrawBendLines(Graphics g, Part part, bool muted) { if ( !view.ShowBendLines @@ -273,7 +275,7 @@ namespace OpenNest.Controls ) return; - using var bendPen = new Pen(Color.Yellow, 1.5f) + using var bendPen = new Pen(muted ? Color.Gray : Color.Yellow, 1.5f) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dash, }; @@ -299,7 +301,7 @@ namespace OpenNest.Controls } } - private void DrawEtchMarks(Graphics g, Part part) + private void DrawEtchMarks(Graphics g, Part part, bool muted) { if ( !view.ShowBendLines @@ -308,7 +310,7 @@ namespace OpenNest.Controls ) return; - using var etchPen = new Pen(Color.Green, 1.5f); + using var etchPen = new Pen(muted ? Color.Gray : Color.Green, 1.5f); var etchLength = 1.0; foreach (var bend in part.BaseDrawing.Bends) @@ -353,7 +355,7 @@ namespace OpenNest.Controls } } - private void DrawGrainWarning(Graphics g, Part part) + private void DrawGrainWarning(Graphics g, Part part, bool muted) { var plate = view.Plate; if ( @@ -389,7 +391,7 @@ namespace OpenNest.Controls var pt2 = view.PointWorldToGraph( new Vector(location.X + box.Length, location.Y + box.Width) ); - using var warnPen = new Pen(Color.FromArgb(180, 255, 140, 0), 2f); + using var warnPen = new Pen(muted ? Color.Gray : Color.FromArgb(180, 255, 140, 0), 2f); g.DrawRectangle( warnPen, pt1.X, diff --git a/OpenNest/Controls/ValidationOverlay.cs b/OpenNest/Controls/ValidationOverlay.cs index dcc2b69..169902c 100644 --- a/OpenNest/Controls/ValidationOverlay.cs +++ b/OpenNest/Controls/ValidationOverlay.cs @@ -88,6 +88,18 @@ internal sealed class ValidationOverlay : IDisposable { DashStyle = DashStyle.Dash }; graphics.DrawLine(rapid, view.PointWorldToGraph(start), view.PointWorldToGraph(end)); } + using var contactHalo = new Pen(Color.White, 5 * dpi); + using var contactPen = new Pen(Color.DeepPink, 2 * dpi); + foreach (var contact in finding.ContactPoints.Where(IsFinite)) + { + var point = view.PointWorldToGraph(contact); + foreach (var pen in new[] { contactHalo, contactPen }) + { + graphics.DrawEllipse(pen, point.X - 8 * dpi, point.Y - 8 * dpi, 16 * dpi, 16 * dpi); + graphics.DrawLine(pen, point.X - 12 * dpi, point.Y, point.X + 12 * dpi, point.Y); + graphics.DrawLine(pen, point.X, point.Y - 12 * dpi, point.X, point.Y + 12 * dpi); + } + } return; } if (finding.Location is not { } location || !double.IsFinite(location.X) || !double.IsFinite(location.Y)) diff --git a/OpenNest/Forms/ValidateNestForm.cs b/OpenNest/Forms/ValidateNestForm.cs index 319eeed..ac4d65e 100644 --- a/OpenNest/Forms/ValidateNestForm.cs +++ b/OpenNest/Forms/ValidateNestForm.cs @@ -89,7 +89,7 @@ public sealed class ValidateNestForm : Form { AutoSize = true, Dock = DockStyle.Fill, - Text = "Select an issue to locate it in the main plate view. Its highlight pulses while selected.\r\n" + Text = "Select an issue to locate it in the main plate view. Its highlight pulses; unrelated parts appear gray.\r\n" + "Minimum spacing, plate boundaries, and final post-processor output are not checked.", Margin = new Padding(3, 3, 3, 10), }; @@ -205,7 +205,9 @@ public sealed class ValidateNestForm : Form var hint = finding.Kind switch { PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.", - PostVerificationKind.RapidCrossing => "The pulsing dashed orange line is the offending rapid segment; it does not mark an exact contact point.", + PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0 + ? "Crosshairs mark contacts with the completed contour. For travel along an edge, they mark the contact span's endpoints. 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.", PostVerificationKind.MissingLeadIn => "The ring marks the cutting contour start where a lead-in is missing.", _ => "The ring marks the affected part's center when available; no exact problem location is known.", }; diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index 39df5f7..85324dd 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -92,23 +92,27 @@ namespace OpenNest SetDisplayColor(fill); } - public void Draw(Graphics g) + public void Draw(Graphics g) => Draw(g, false); + + private void Draw(Graphics g, bool muted) { RefreshColors(); using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty ? new SolidBrush(colorScheme.SelectedPartColor) : null; - g.FillPath(schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path); - DrawEtch(g); + g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path); + DrawEtch(g, muted); // Keep real cuts visible even where an etch overlaps them. - g.DrawPath(IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path); + g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path); if (LeadInPath != null) - g.DrawPath(leadInPen, LeadInPath); + g.DrawPath(muted ? Pens.Gray : leadInPen, LeadInPath); } - public void Draw(Graphics g, string id) + public void Draw(Graphics g, string id) => Draw(g, id, false); + + internal void Draw(Graphics g, string id, bool muted) { - Draw(g); + Draw(g, muted); using var sf = new StringFormat { @@ -118,7 +122,7 @@ namespace OpenNest g.DrawString( id, programIdFont, - Brushes.Black, + muted ? Brushes.DimGray : Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf @@ -127,11 +131,11 @@ namespace OpenNest public GraphicsPath OffsetPath { get; private set; } - internal void DrawEtch(Graphics g) + internal void DrawEtch(Graphics g, bool muted = false) { if (EtchPath == null || EtchPath.PointCount == 0) return; - using var etchPen = new Pen(colorScheme.EtchColor, 1.5f); + using var etchPen = new Pen(muted ? Color.Gray : colorScheme.EtchColor, 1.5f); g.DrawPath(etchPen, EtchPath); } diff --git a/docs/post-verification.md b/docs/post-verification.md index 834fc59..4320c28 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -10,13 +10,21 @@ numbers in a read-only grid like the cut planner; incomplete or failed checks ar never reported as clear. Select a row to switch the main plate view to that plate and focus the finding. The inspector opens at the right of the screen and can be moved or resized. The details below the grid explain the selected warning. +While a finding is selected, its named parts retain their normal colors; other +parts use muted gray fills and visible gray outlines, lead-ins, and marks. Selecting +another finding updates the emphasis. Clearing the finding or closing validation +restores normal rendering without changing drawing colors or selection. Overlaps use the same magenta material shading as **View > Overlap Check**. A DPI-scaled ring expands and fades continuously while the finding is selected; both its color and contrasting halo fade as it grows. For overlaps the ring marks the area centroid (possibly between separate patches); for missing lead-ins it marks the contour start. Rapid warnings -pulse the offending orange dashed segment continuously, without a ring or an -exact contact-point marker. Incomplete findings use the part center when available; +pulse the offending orange dashed segment continuously and mark its contacts with +the completed contour using fixed-screen-size magenta crosshairs with white halos. +Contacts use native lines/arcs and the rapid check's existing numerical tolerances; +shared vertices and tangencies get one marker, and travel along an edge marks the +contact span endpoints. Start-only departure is excluded. Travel entirely inside +a contour has no boundary contact markers. Incomplete findings use the part center when available; findings without a usable location do not invent a marker. Closing the inspector clears the highlight and restores the original plate, zoom, and overlap display mode.