Files
OpenNest/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs
T
aj ea1270e1e7 fix(cutting): check every lead against all other material again
The second delta review compared the filtered checks with the previous
implementation on 5,400 generated cases. Rapid checks matched in every
case, but 114 lead checks differed: a long lead passing a small circle
or arc well over 0.001 away was reported clear, while the native
line/circle query, through rounding in its squared terms, reports a
contact there. A coordinate limit and a fixed margin cannot bound that
cancellation.

Lead checks therefore examine every other part's material again,
exactly as before the filter; LeadMaterialSnapshot no longer keeps an
extent. Rapid checks, including the pre-post review's, keep skipping
completed contours more than 0.001 clear of the rapid. The 900000-long
lead beside a radius-0.0001 circle is a regression test.

Planning a dense 144-part grid now takes about 19 s again (lead checks
dominate); a new part order is still found where the old search gave
up.
2026-10-06 00:47:46 -04:00

291 lines
13 KiB
C#

using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// Rapid checks skip completed contours whose extents are well clear of the motion; lead checks
/// examine every other part's material. These cases sit just inside the skip, or show that lead
/// checks are never filtered, and must keep the native result.
/// </summary>
public class NearbyMaterialCheckTests
{
[Fact]
public void LeadArcBulgingIntoMaterialBeyondItsEndpoints_IsRefused()
{
// The lead arc runs from (-2, 7) through (-1, 4.59) to the joint (0, 5); the other part's
// material lies wholly below both endpoints but inside the arc's sweep.
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero);
var lead = new Program();
lead.MoveTo(-2, 7);
lead.Codes.Add(new ArcMove(new Vector(0, 5), new Vector(-1, 6), RotationType.CCW) { Layer = LayerType.Leadin });
lead.LineTo(0, 10);
var execution = ExecutionMotionReader.Read(lead, Vector.Zero, null, default);
Assert.True(LeadPathValidator.Check(execution, target, [target]).IsClear);
var below = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(-1.2, 4.3, -0.8, 4.8), Vector.Zero);
Assert.True(below.IsComplete, below.Reason);
var result = LeadPathValidator.Check(execution, target, [target, below]);
Assert.True(result.IsComplete, result.Reason);
Assert.False(result.IsClear);
}
[Fact]
public void LeadEndingOnAnotherPartsEdge_IsRefused()
{
// The other part's extent only touches the lead's: separation zero, never skipped.
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero);
var lead = new Program();
lead.MoveTo(-3, 5);
lead.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
lead.LineTo(0, 10);
var execution = ExecutionMotionReader.Read(lead, Vector.Zero, null, default);
var touching = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(-4, 5, -3, 6), Vector.Zero);
var result = LeadPathValidator.Check(execution, target, [target, touching]);
Assert.True(result.IsComplete, result.Reason);
Assert.False(result.IsClear);
}
[Fact]
public void RapidAcrossOnlyTheBulgeOfACompletedArc_IsACrossing()
{
// A "D": straight side x = 0 from (0, 0) to (0, 4), arc bulging right to x = 2. Every
// endpoint has x = 0; the rapid at x = 1 crosses only the bulge.
var state = new ReleasedContourState();
var d = new Program();
d.MoveTo(-0.5, 0);
d.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadin });
d.LineTo(0, 4);
d.Codes.Add(new ArcMove(new Vector(0, 0), new Vector(0, 2), RotationType.CW));
Assert.Empty(state.Check(Read(d), Vector.Zero, 1).Where(f => f.Kind == PostVerificationKind.RapidCrossing));
var findings = state.Check(Read(Square(1, 5)), new Vector(1, -1), 2);
Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1);
}
[Fact]
public void RapidEndingOnACompletedCorner_IsACrossing()
{
// The extents meet only at the corner (2, 2).
var state = new ReleasedContourState();
state.Check(Read(Square(0, 0)), Vector.Zero, 1);
var findings = state.Check(Read(Square(3, 3, new Vector(2, 2))), new Vector(5, -1), 2);
Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1);
}
[Fact]
public void LeadCheck_ExaminesMaterialWellClearOfTheLead()
{
// A 900000-long lead passes 0.0021 from a radius-0.0001 circle: far more than any skip
// margin, yet rounding in the native line/circle query reports a contact. Lead checks keep
// that native result rather than skipping the circle.
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, -2, 2, 0.0021), Vector.Zero);
var circle = new Program();
circle.MoveTo(-1 + 0.0001, 0);
circle.Codes.Add(new ArcMove(new Vector(-1 + 0.0001, 0), new Vector(-1, 0), RotationType.CCW));
var far = LeadMaterialSnapshot.Capture(circle, Vector.Zero);
Assert.True(far.IsComplete, far.Reason);
var lead = new Program();
lead.MoveTo(-900000, 0.0021);
lead.Codes.Add(new LinearMove(0, 0.0021) { Layer = LayerType.Leadin });
lead.LineTo(0, -2);
var execution = Read(lead);
Assert.True(LeadPathValidator.Check(execution, target, [target]).IsClear);
var result = LeadPathValidator.Check(execution, target, [target, far]);
Assert.True(result.IsComplete, result.Reason);
Assert.False(result.IsClear);
}
[Fact]
public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing()
{
// A circle of radius 0.001 counts as touched up to sqrt(r^2 + 1e-8) from its centre, beyond
// its radius; the rapid passes 0.000002 outside the radius, inside that band.
var state = new ReleasedContourState();
Assert.Empty(state.Check(Read(Circle(0.001)), Vector.Zero, 1)
.Where(f => f.Kind == PostVerificationKind.RapidCrossing));
var next = new Program();
next.MoveTo(0.001002, 0.002);
var findings = state.Check(Read(next), new Vector(0.001002, -0.002), 2);
Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1);
}
[Fact]
public void PrePostReview_RapidJustOutsideASmallCircle_IsStillReported()
{
const double radius = 0.003;
const double y = radius + 0.0000015;
var first = new Part(new Drawing("circle", CleanCircle(radius)));
var circle = Circle(radius);
circle.Codes.Add(new LinearMove(-0.01, y) { Layer = LayerType.Scribe }); // Ends left of the circle.
Assert.True(first.RestoreLeadInProgram(circle, true));
var second = new Part(new Drawing("square",
LeadPathValidationTests.Rectangle(0.01, y + 0.0002, 0.02, y + 0.0102)));
var square = new Program();
square.MoveTo(0.01, y);
square.Codes.Add(new LinearMove(0.01, y + 0.0002) { Layer = LayerType.Leadin });
square.LineTo(0.01, y + 0.0102); square.LineTo(0.02, y + 0.0102);
square.LineTo(0.02, y + 0.0002); square.LineTo(0.01, y + 0.0002);
Assert.True(second.RestoreLeadInProgram(square, true));
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Parts.Add(first);
plate.Parts.Add(second);
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing
&& f.PartNumber == 2 && f.OtherPartNumber == 1);
}
[Fact]
public void LeadGrazingASmallCircle_StaysAnUncertainCheck()
{
// The lead passes 0.0000015 outside a radius-0.003 circle: inside its native contact band,
// where the native query cannot certify contact either way.
const double radius = 0.003;
const double y = radius + 0.0000015;
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0.01, y, 0.02, y + 0.01), Vector.Zero);
var other = LeadMaterialSnapshot.Capture(CleanCircle(radius), Vector.Zero);
Assert.True(other.IsComplete, other.Reason);
var lead = new Program();
lead.MoveTo(-0.01, y);
lead.Codes.Add(new LinearMove(0.01, y) { Layer = LayerType.Leadin });
lead.LineTo(0.01, y + 0.01);
var result = LeadPathValidator.Check(Read(lead), target, [target, other]);
Assert.False(result.IsComplete);
Assert.Contains("uncertain", result.Reason);
}
[Fact]
public void LeadEndingWithinTheNativeBoxAllowanceOfAnotherPart_IsRefused()
{
// The other part lies 0.000003 above the lead: outside both extents, but within the
// 0.00001 allowance the native line intersection grants its bounding boxes.
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, -2, 2, 0), Vector.Zero);
var other = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(-1, 0.000003, -0.25, 1), Vector.Zero);
Assert.True(other.IsComplete, other.Reason);
var lead = new Program();
lead.MoveTo(-0.5, 0);
lead.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadin });
lead.LineTo(0, -2);
var result = LeadPathValidator.Check(Read(lead), target, [target, other]);
Assert.True(result.IsComplete, result.Reason);
Assert.False(result.IsClear);
}
[Fact]
public void RapidBesideAHugeCompletedCircle_IsStillACrossing()
{
// Radius 5e11 about (5e11, 0): its extent starts at x = 0, but at this size rounding lets
// the native query count a rapid at x = -0.00001 as touching it.
var state = new ReleasedContourState();
state.Check(Read(HugeCircle()), null, 1);
var rapid = new Program();
rapid.MoveTo(-1e-5, 1);
var findings = state.Check(Read(rapid), new Vector(-1e-5, -1), 2);
Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1);
}
[Fact]
public void LeadBesideAHugeCircle_IsRefused()
{
var huge = LeadMaterialSnapshot.Capture(HugeCircle(), Vector.Zero);
Assert.True(huge.IsComplete, huge.Reason);
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(-2, -1, -0.002, -0.5), Vector.Zero);
var lead = new Program();
lead.MoveTo(-1e-5, 1);
lead.Codes.Add(new LinearMove(-1e-5, -0.5) { Layer = LayerType.Leadin });
lead.Codes.Add(new LinearMove(-0.002, -0.5) { Layer = LayerType.Leadin });
lead.LineTo(-2, -0.5);
var result = LeadPathValidator.Check(Read(lead), target, [target, huge]);
Assert.True(result.IsComplete, result.Reason);
Assert.False(result.IsClear);
}
[Theory]
[InlineData(2.002, 0, true)] // 0.002 apart: clear of the 0.001 margin.
[InlineData(2.0005, 0, false)] // 0.0005 apart: inside the margin.
[InlineData(2.002, 2e6, false)] // Clear, but beyond the well-conditioned range: always checked.
public void IsClearOf_OnlyWellApartAndWellConditionedExtentsAreSkipped(double left, double offset, bool clear)
{
var a = new PostVerificationGeometry.Extent(offset, offset, offset + 2, offset + 2);
var b = new PostVerificationGeometry.Extent(offset + left, offset, offset + left + 1, offset + 1);
Assert.Equal(clear, a.IsClearOf(b));
Assert.Equal(clear, b.IsClearOf(a));
}
[Fact]
public void ExtentWithANaNBound_IsNeverSeparated()
{
var partial = new PostVerificationGeometry.Extent(double.NaN, 0, double.NaN, 1);
var other = new PostVerificationGeometry.Extent(0, 4, 1, 5);
Assert.False(partial.IsClearOf(other));
Assert.False(other.IsClearOf(partial));
Assert.False(PostVerificationGeometry.Extent.None.IsClearOf(other)); // Infinite bounds.
}
private static Program HugeCircle()
{
var p = new Program();
p.MoveTo(0, 0);
p.Codes.Add(new ArcMove(Vector.Zero, new Vector(5e11, 0), RotationType.CCW));
return p;
}
private static Program CleanCircle(double radius)
{
var p = new Program();
p.MoveTo(radius, 0);
p.Codes.Add(new ArcMove(new Vector(radius, 0), Vector.Zero, RotationType.CCW));
return p;
}
// A full circle about the origin cut after a lead-in from 0.0002 outside it.
private static Program Circle(double radius)
{
var p = new Program();
p.MoveTo(radius + 0.0002, 0);
p.Codes.Add(new LinearMove(radius, 0) { Layer = LayerType.Leadin });
p.Codes.Add(new ArcMove(new Vector(radius, 0), Vector.Zero, RotationType.CCW));
return p;
}
// A 2 x 2 square at (x, y) with a lead-in from 0.5 below its corner, optionally preceded by
// a rapid to a given point.
private static Program Square(double x, double y, Vector? via = null)
{
var p = new Program();
if (via is { } point)
p.MoveTo(point.X, point.Y);
p.MoveTo(x, y - 0.5);
p.Codes.Add(new LinearMove(x, y) { Layer = LayerType.Leadin });
p.LineTo(x, y + 2); p.LineTo(x + 2, y + 2); p.LineTo(x + 2, y); p.LineTo(x, y);
return p;
}
private static OwnedExecution Read(Program p) => ExecutionMotionReader.Read(p, Vector.Zero, null, default);
}