diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 7d930e8..50c1d1a 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -14,12 +14,20 @@ public sealed class LeadMaterialSnapshot { Rings = rings; Reason = reason; + var extent = PostVerificationGeometry.Extent.None; + foreach (var ring in rings) + foreach (var curve in ring) + extent = extent.Union(curve.Extent); + Extent = extent; } public bool IsComplete => Reason == null; public string Reason { get; } internal IReadOnlyList Rings { get; } + /// A conservative extent of every boundary; material lies within it. + internal PostVerificationGeometry.Extent Extent { get; } + /// Capture a stable, clean, rotation-baked program, applying location once. /// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws. public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location, diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index c354417..cec569e 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -13,6 +13,13 @@ 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) { @@ -84,10 +91,14 @@ public static class LeadPathValidator var failure = CheckMaterial(target, allowed); 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(); - if (ReferenceEquals(material, target)) + // 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)) 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 e92028c..be454bb 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -10,6 +10,12 @@ 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() @@ -56,9 +62,16 @@ public sealed class ReleasedContourState hasLead = false; if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) continue; + 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)) + continue; if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + @@ -127,5 +140,9 @@ public sealed class ReleasedContourState } } - private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves); + private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves) + { + internal PostVerificationGeometry.Extent Extent { get; } = + Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent)); + } } diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 697395f..b577764 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -11,6 +11,29 @@ internal static class PostVerificationGeometry internal const double Epsilon = 1e-8; private const double TwoPi = 2 * System.Math.PI; + /// An axis-aligned extent used only to skip queries that cannot meet. + internal readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY) + { + /// No extent: unions with anything give the other extent. + internal static Extent None => new(double.PositiveInfinity, double.PositiveInfinity, + double.NegativeInfinity, double.NegativeInfinity); + + 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))); + + /// + /// True only when the extents are farther apart than on some axis, + /// so nothing inside one can touch or enter the other. NaN bounds are never separated. + /// + internal bool IsSeparatedFrom(Extent other, double margin) => + MaxX + margin < other.MinX || other.MaxX + margin < MinX + || MaxY + margin < other.MinY || other.MaxY + margin < MinY; + } + internal static void Validate(Vector point) { if (!double.IsFinite(point.X) || !double.IsFinite(point.Y) @@ -82,6 +105,15 @@ internal static class PostVerificationGeometry : new Arc(center, Radius, Normalize(StartAngle), Normalize(StartAngle + Sweep), Sweep < 0) : new Line(Start, End); + /// + /// A conservative axis-aligned extent: an arc's whole supporting circle, a line's endpoints. + /// Nonfinite geometry yields NaN bounds, which no separation test can pass. + /// + internal Extent Extent => Center is { } center + ? new(center.X - Radius, center.Y - Radius, center.X + Radius, center.Y + Radius) + : new(System.Math.Min(Start.X, End.X), System.Math.Min(Start.Y, End.Y), + System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y)); + internal Vector Midpoint => Center is { } center ? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2), center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) diff --git a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs new file mode 100644 index 0000000..4f83c89 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs @@ -0,0 +1,97 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The lead and rapid checks skip material and contours whose extents are well clear of the +/// motion. These cases sit just inside that skip and must still be checked. +/// +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); + } + + // 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); +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 73a6c04..549a986 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -86,7 +86,10 @@ motions, never the nominal entry point alone. Existing lead styles are not 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. Actual +earlier holes in the same part. Future contours are not yet obstacles. Rapid and +lead checks skip contours and material whose extents (an arc's whole supporting +circle) are more than 1e-6 x (1 + coordinate size) clear of the motion; anything +closer, touching included, gets the full native check. 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.