Files
OpenNest/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

376 lines
17 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
/// skip other material only when the gap also covers native line/circle rounding over the span
/// of both extents, so a long lead beside a small circle is still checked. These cases sit just
/// inside the skips 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 MemoizedLeadCheck_ReturnsTheNativeResultOnEveryCall()
{
// The search re-checks the same emitted leads on every prefix of a part. Remembered
// answers must be the native ones, the rounding contact far from a small circle included.
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);
var clear = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(5, 5, 6, 6), Vector.Zero);
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 memo = new LeadContactMemo();
foreach (var others in new[] { new[] { target, clear }, [target, clear, far], [far, target] })
{
var expected = LeadPathValidator.Check(Read(lead), target, others);
for (var call = 0; call < 2; call++)
{
var actual = LeadPathValidator.Check(Read(lead), target, others, memo, default);
Assert.Equal(expected, actual);
}
}
Assert.True(memo.Hits > 0);
}
[Fact]
public void LeadCheck_SkipsOnlyMaterialWithNoNativeContact()
{
// Leads from 0.01 to 900000 long, lines and arcs, beside circles and rectangles placed just
// beyond the skip gap, often near the lead's own line where the native line/circle
// discriminant cancels. Whatever is skipped must be clear by the full native scan too.
var random = new Random(11);
var skipped = 0;
for (var i = 0; i < 4000; i++)
{
var length = System.Math.Pow(10, random.NextDouble() * 7.95 - 2);
// Only a nearly axis-aligned lead has an extent thin enough to pass beside material.
var angle = random.Next(4) * System.Math.PI / 2
+ (random.Next(2) == 0 ? 0 : (random.NextDouble() - 0.5) * 1e-6);
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
var start = new Vector(random.NextDouble() * 200 - 100, random.NextDouble() * 200 - 100);
var end = start + direction * length;
var lead = random.Next(3) == 0
? PostVerificationGeometry.Curve.Create(start, end, (start + end) * 0.5
+ new Vector(-direction.Y, direction.X) * (length * random.NextDouble()), random.Next(2) == 0)
: PostVerificationGeometry.Curve.Create(start, end, null, false);
var size = System.Math.Pow(10, random.NextDouble() * 5 - 4);
var normal = new Vector(-direction.Y, direction.X) * (random.Next(2) == 0 ? 1 : -1);
// Beside the lead, just past either end, or anywhere along its line.
var along = random.Next(3) switch
{
0 => -random.NextDouble() * length * 0.1,
1 => length * (1 + random.NextDouble() * 0.1),
_ => random.NextDouble() * length,
};
var span = length + 2 * size;
var gap = (PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding * span)
* (1 + random.NextDouble()) + size;
var center = start + direction * along + normal * (random.Next(4) == 0 ? 0 : gap);
var program = random.Next(2) == 0
? Circle(center, size)
: LeadPathValidationTests.Rectangle(center.X - size, center.Y - size, center.X + size, center.Y + size);
var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
if (!material.IsComplete || !lead.Extent.IsClearOfQueriesFrom(material.Extent))
continue;
skipped++;
var budget = int.MaxValue;
Assert.Null(LeadPathValidator.MaterialContact(lead, material, null, ref budget, default));
}
Assert.True(skipped > 1000, $"Only {skipped} cases were skipped.");
static Program Circle(Vector center, double radius)
{
var p = new Program();
p.MoveTo(center.X + radius, center.Y);
p.Codes.Add(new ArcMove(new Vector(center.X + radius, center.Y), center, RotationType.CCW));
return p;
}
}
[Fact]
public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing()
{
// A circle of radius 0.001 counts as touched up to sqrt(r^2 + 1e-8) from its center, 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);
}