Highlight validation contacts and mute unrelated parts

This commit is contained in:
aj committed 2026-10-10 15:11:41 -04:00
1 parent bb108e9341
commit d59c03f5ab
12 files changed
+223 -30

No files matched your search

@@ -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)
@@ -89,6 +89,30 @@ internal static class PostVerificationGeometry
return inside;
}
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token)
{
var length = start.DistanceTo(end);
if (length <= Epsilon)
return Array.Empty<Vector>();
var direction = (end - start) * (1 / length);
var distances = new List<double>();
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
curve.ContactAfterStart(start, direction, length, distances);
}
distances.Sort();
var points = new List<Vector>();
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<double> 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)
@@ -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; }
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
public PlateOverlapPair Overlap { get; init; }
}
@@ -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);
}
@@ -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<Vector>)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));
}
}
@@ -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()
@@ -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)]
+12 -10
View File
@@ -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,
+12
View File
@@ -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))
+4 -2
View File
@@ -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.",
};
+14 -10
View File
@@ -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);
}
+10 -2
View File
@@ -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.