From bfe2e518e6ad0e5700c633fc37d7a0be158af7ef Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 5 Oct 2026 23:41:02 -0400 Subject: [PATCH] perf(cutting): skip lead and rapid checks against distant material Every lead was checked against every placed part's material and every rapid against every completed contour, so each check cost O(parts) and planning a plate cost O(parts^2). Lead checks were 88% of planning time on a 144-part grid. LeadMaterialSnapshot and each completed contour now keep a conservative extent (an arc counts as its whole supporting circle). A lead or rapid skips material or a contour only when the extents are farther apart than 1e-6 x (1 + coordinate size), far above the contact tolerance, so results are unchanged: anything touching or closer still gets the full native check. The rapid filter also applies to pre-post verification, which shares ReleasedContourState. New tests cover the cases just inside the skip: an arc lead and a completed arc whose bulge reaches past their endpoints, and a lead and a rapid that only touch another part's extent. --- .../CuttingPlanning/LeadMaterialSnapshot.cs | 8 ++ .../CNC/CuttingPlanning/LeadPathValidator.cs | 13 ++- .../CuttingPlanning/ReleasedContourState.cs | 19 +++- .../Diagnostics/PostVerificationGeometry.cs | 32 ++++++ .../NearbyMaterialCheckTests.cs | 97 +++++++++++++++++++ docs/cutting-planner.md | 5 +- 6 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs 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.