diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index cfff678..aefaff5 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
internal static class PostVerificationGeometry
{
internal const double Epsilon = 1e-8;
+ // Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
+ // Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
+ // exterior point to interior (see Curve.CrossesRay).
+ internal const double RaySeamTolerance = 1e-6;
private const double TwoPi = 2 * System.Math.PI;
/// An axis-aligned extent used only to skip queries that cannot meet.
@@ -385,8 +389,18 @@ internal static class PostVerificationGeometry
internal bool CrossesRay(Vector point)
{
+ // Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
+ // that vertex is on. Rings are only nominally closed: capture admits closure seams and
+ // PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
+ // comparison can split one vertex across the ray and report far-away exterior points as
+ // interior — which made LeadPathValidator refuse leads that could touch nothing.
+ // Snapping within the admitted seam tolerance keeps the decision consistent on both
+ // edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
+ // still count. Containment of a point that itself lies within the tolerance of a
+ // boundary edge stays ambiguous by design, exactly as it was before.
+ var rayY = point.Y + RaySeamTolerance;
if (Center is not { } center)
- return (Start.Y > point.Y) != (End.Y > point.Y)
+ return (Start.Y > rayY) != (End.Y > rayY)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
@@ -407,7 +421,7 @@ internal static class PostVerificationGeometry
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
- if ((ya > point.Y) == (yb > point.Y))
+ if ((ya > rayY) == (yb > rayY))
continue;
var dy = point.Y - center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
diff --git a/OpenNest.Tests/CuttingPlanning/MaterialContainmentTests.cs b/OpenNest.Tests/CuttingPlanning/MaterialContainmentTests.cs
new file mode 100644
index 0000000..35ed8cc
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/MaterialContainmentTests.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using Xunit;
+using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// Captured material outlines are only nominally closed: capture admits closure slack up to
+/// NominalContourClosure.Tolerance, and PEP-converted or rotated geometry carries sub-micro-inch
+/// vertex jogs where two segments meet. The point-in-material ray test must stay consistent across
+/// such a jog. Without that, a jog lying exactly on the test ray's Y reports far-away exterior
+/// points as interior, so LeadPathValidator refuses leads that cannot touch anything: plans get
+/// needlessly expensive routes or give up inside the search budget.
+///
+public class MaterialContainmentTests
+{
+ [Fact]
+ public void SubMicroinchJogAtRayHeight_KeepsDistantExteriorPointExterior()
+ {
+ var rings = Ring(
+ Line((21.2, 0.6), (31.2, 0.6)),
+ Line((31.2, 0.6000001), (41.2, 0.6000001)), // admitted seam jog on the ray line
+ Line((41.2, 0.6000001), (41.2, 46.16)),
+ Line((41.2, 46.16), (21.2, 46.16)),
+ Line((21.2, 46.16), (21.2, 0.6)));
+
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.6), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.6000001), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(20.75, 0.60000005), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(30, 20), rings[0], CancellationToken.None));
+ }
+
+ [Fact]
+ public void ClosedWithoutJog_ReportsTheSameAnswersAsTheJoggedCopy()
+ {
+ var clean = Ring(
+ Line((21.2, 0.6), (41.2, 0.6)),
+ Line((41.2, 0.6), (41.2, 46.16)),
+ Line((41.2, 46.16), (21.2, 46.16)),
+ Line((21.2, 46.16), (21.2, 0.6)));
+ var jogged = Ring(
+ Line((21.2, 0.6), (31.2, 0.6)),
+ Line((31.2, 0.6000001), (41.2, 0.6000001)),
+ Line((41.2, 0.6000001), (41.2, 46.16)),
+ Line((41.2, 46.16), (21.2, 46.16)),
+ Line((21.2, 46.16), (21.2, 0.6)));
+
+ foreach (var y in new[] { 0.6, 0.60000005, 0.6000001 })
+ {
+ var expected = LeadMaterialSnapshot.Inside(new Vector(20.75, y), clean[0], CancellationToken.None);
+ Assert.Equal(expected, LeadMaterialSnapshot.Inside(new Vector(20.75, y), jogged[0], CancellationToken.None));
+ }
+ }
+
+ [Fact]
+ public void CircleTestedExactlyAtAnExtremum_KeepsExteriorExteriorAndInteriorInterior()
+ {
+ // Full circle r=5 about (10,10) as four quarter arcs. Testing the ray exactly along both
+ // vertical extrema is where the half-open rule and arc splitting have to agree.
+ var center = new Vector(10, 10);
+ var rings = Ring(
+ Curve.Create(new Vector(15, 10), new Vector(10, 15), center, false),
+ Curve.Create(new Vector(10, 15), new Vector(5, 10), center, false),
+ Curve.Create(new Vector(5, 10), new Vector(10, 5), center, false),
+ Curve.Create(new Vector(10, 5), new Vector(15, 10), center, false));
+
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 10), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 5), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 15), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(10, 10), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(7.5, 10), rings[0], CancellationToken.None));
+ }
+
+ [Fact]
+ public void SeamJogBetweenLineAndArc_KeepsDistantExteriorPointExterior()
+ {
+ // Rounded-left-edge material whose semicircular edge meets the bottom line 1e-7 apart:
+ // an admitted closure seam sitting exactly on a datum Y shared with neighbours.
+ var center = new Vector(1, 23.38000005);
+ var rings = Ring(
+ Line((1, 0.6), (41.2, 0.6)),
+ Line((41.2, 0.6), (41.2, 46.16)),
+ Line((41.2, 46.16), (1, 46.16)),
+ Curve.Create(new Vector(1, 46.16), new Vector(1, 0.6000001), center, false));
+
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(0.5, 0.6), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 0.6), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-4, 0.6000001), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(30, 20), rings[0], CancellationToken.None));
+ }
+
+ [Fact]
+ public void RealConcavityIsNotErasedByTheRayTolerance()
+ {
+ // A 0.01 in deep notch is far above any admitted seam tolerance: it must still count.
+ var rings = Ring(
+ Line((0, 0), (1, 0)),
+ Line((1, 0), (1, 1)),
+ Line((1, 1), (0.5, 1)),
+ Line((0.5, 1), (0.5, 0.99)), // notch floor
+ Line((0.5, 0.99), (0, 0.99)),
+ Line((0, 0.99), (0, 0)));
+
+ // The 0.01 in notch sits above the left part's top edge; the right tower rises through it.
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(0.25, 0.995), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(0.75, 0.995), rings[0], CancellationToken.None));
+ Assert.True(LeadMaterialSnapshot.Inside(new Vector(0.25, 0.5), rings[0], CancellationToken.None));
+ Assert.False(LeadMaterialSnapshot.Inside(new Vector(-1, 0.5), rings[0], CancellationToken.None));
+ }
+
+ private static IReadOnlyList> Ring(params Curve[] curves) =>
+ new List> { new List(curves) };
+
+ private static Curve Line((double X, double Y) start, (double X, double Y) end) =>
+ Curve.Create(new Vector(start.X, start.Y), new Vector(end.X, end.Y), null, false);
+}