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); +}