diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index cec569e..dde1eea 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -13,13 +13,6 @@ public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, str /// Certifies actual emitted native lead paths against owned nominal material. public static class LeadPathValidator { - /// - /// Relative separation beyond which another material is skipped. Scaled by coordinate size, - /// it stays far above both the contact tolerance and the rounding of the extents themselves, - /// so a lead touching a boundary within tolerance is always checked. - /// - internal const double ClearanceMargin = 1e-6; - public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, IReadOnlyList otherMaterials, CancellationToken token = default) { @@ -92,13 +85,12 @@ public static class LeadPathValidator if (failure != null) return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."); var reach = move.Curve.Extent; - var margin = ClearanceMargin * (1 + reach.Magnitude); foreach (var material in otherMaterials) { token.ThrowIfCancellationRequested(); // Material lies within its extent, so a lead well clear of that extent can // neither touch it nor lie inside it. - if (ReferenceEquals(material, target) || reach.IsSeparatedFrom(material.Extent, margin)) + if (ReferenceEquals(material, target) || reach.IsClearOf(material.Extent)) continue; if (CheckMaterial(material, null) != null) return new(true, false, "Lead contacts or enters another placed material."); diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index be454bb..f975009 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -10,12 +10,6 @@ namespace OpenNest.CNC.CuttingPlanning; /// Direct XY completed-contour checker. This is not physical machine safety. public sealed class ReleasedContourState { - /// - /// Relative separation beyond which a completed contour is skipped. Scaled by coordinate size, - /// it stays far above the contact tolerance and the rounding of the extents themselves. - /// - internal const double ClearanceMargin = 1e-6; - private readonly List obstacles = new(); public ReleasedContourState Copy() @@ -65,12 +59,11 @@ public sealed class ReleasedContourState var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X), System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X), System.Math.Max(start.Y, move.End.Y)); - var margin = ClearanceMargin * (1 + reach.Magnitude); foreach (var obstacle in obstacles) { token.ThrowIfCancellationRequested(); // A rapid well clear of a contour's extent can neither cross nor touch it. - if (reach.IsSeparatedFrom(obstacle.Extent, margin)) + if (reach.IsClearOf(obstacle.Extent)) continue; if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 0711cc3..4de99cc 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -21,22 +21,34 @@ internal static class PostVerificationGeometry internal Extent Union(Extent other) => new(System.Math.Min(MinX, other.MinX), System.Math.Min(MinY, other.MinY), System.Math.Max(MaxX, other.MaxX), System.Math.Max(MaxY, other.MaxY)); - /// The largest absolute coordinate, for scaling tolerances; NaN when any bound is. - internal double Magnitude => System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)), - System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))); + /// + /// The gap two extents need before their checks may be skipped: ten times the widest + /// absolute band any native contact query allows beyond an extent (the 0.00001 bounding-box + /// allowance of native intersections, the 0.0001 contact reach of tiny arcs). + /// + internal const double ClearMargin = 1e-3; /// - /// True only when the extents are farther apart than on some axis, - /// so nothing inside one can touch or enter the other. An extent with any nonfinite bound - /// (including the empty one) is never separated. + /// Coordinates up to which extents may be skipped at all. Within it rounding stays far below + /// ; beyond it, where rounding of large supports can exceed any fixed + /// margin, every check runs. /// - internal bool IsSeparatedFrom(Extent other, double margin) => IsFinite && other.IsFinite - && double.IsFinite(margin) - && (MaxX + margin < other.MinX || other.MaxX + margin < MinX - || MaxY + margin < other.MinY || other.MaxY + margin < MinY); + internal const double WellConditionedLimit = 1e6; - private bool IsFinite => double.IsFinite(MinX) && double.IsFinite(MinY) - && double.IsFinite(MaxX) && double.IsFinite(MaxY); + /// + /// True only when both extents are finite, lie within and + /// are more than apart on some axis, so no native query can count + /// anything in one as touching or entering the other. Everything else must be checked. + /// + internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned + && (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX + || MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY); + + // Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds + // (including the empty extent's) fail this test too. + private bool IsWellConditioned => + System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)), + System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit; } internal static void Validate(Vector point) diff --git a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs index 15db0d8..9a2e7a9 100644 --- a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs @@ -145,14 +145,88 @@ public class NearbyMaterialCheckTests 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.IsSeparatedFrom(other, 1e-6)); - Assert.False(other.IsSeparatedFrom(partial, 1e-6)); + 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) diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 1e2ce36..3f7fda4 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -102,12 +102,13 @@ shortened, disabled or substituted as a search fallback. Every candidate rapid is checked against contours already completed, including earlier holes in the same part. Future contours are not yet obstacles. Rapid and -lead checks skip contours and material whose extents are more than 1e-6 x -(1 + coordinate size) clear of the motion; anything closer, touching included, gets -the full native check. An arc's extent is its whole supporting circle widened to -the distance at which the native contact query still counts it as touched (for a -very small arc up to 1e-4 beyond its radius), and an extent with any nonfinite -bound is never skipped. Actual +lead checks skip contours and material whose extents are more than 0.001 clear +of the motion, ten times the widest band any native contact query allows beyond an +extent; anything closer, touching included, gets the full native check. An arc's +extent is its whole supporting circle widened to the distance at which the native +contact query still counts it as touched (for a very small arc up to 0.0001 beyond +its radius). Nothing is skipped when either extent has a nonfinite bound or reaches +beyond 1e6, where rounding of large supports can exceed any fixed margin. Actual lead-in and lead-out line/arc paths must stay in target scrap and avoid other placed material; holes in other parts remain scrap. Tangent/coincident contacts outside the genuine target contour joint and numerically uncertain queries refuse.