fix(cutting): keep ray parity consistent across nominal closure seams

Point-in-material used exact Y comparisons, but rings are only nominally
closed: capture admits closure seams and PEP-converted or rotated geometry
carries sub-micro-inch vertex jogs. When a jog straddled the test ray's Y,
the two edges at that shared vertex disagreed which side it was on and the
parity flipped, so a point ten inches outside a material read as interior.
LeadMaterialSnapshot reported leads as entering a neighbour they could not
touch, and the enclosing-perimeter check refused to capture materials whose
rings it misclassified.

Snap the ray comparison by the admitted seam tolerance so both edges at a
shared vertex decide together. Features larger than the tolerance still
count, and containment of a point within the tolerance of an edge stays as
ambiguous as before. On the 93-plate corpus 83 plates are untouched, one
previously refused plate now plans and replays, and 48/93 plates reach
Ready. Accepting more leads also changed one plate's first-found route to a
longer rapid path; that is search ranking, recorded with the measurements.

Also drops an extent-prefilter fast path measured at -6.8% corpus time: it
cleared leads whose native contact was reported beyond any fixed bbox
margin, which NearbyMaterialCheckTests deliberately pins as a refusal.
This commit is contained in:
aj committed 2026-10-12 03:50:54 -04:00
1 parent df94824054
commit e3bcc77145
2 files changed
+137 -2

No files matched your search

@@ -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;
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
@@ -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));
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<IReadOnlyList<Curve>> Ring(params Curve[] curves) =>
new List<IReadOnlyList<Curve>> { new List<Curve>(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);
}