From 94898225e8566657180e56d211826e33cdfcbd90 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 19:50:29 -0400 Subject: [PATCH] perf(planning): skip lead checks against material no native query can reach A lead check scanned every other part's material with native contact queries. On the 5,742-washer plate that was about 80% of planning and it never finished inside 120 s. The earlier extent filter (reverted in ea1270e1) used a fixed 0.001 gap, which native line/circle rounding can exceed for long leads beside small circles. This skip adds 1e-6 times the span of both extents. The discriminant's rounding is bounded by about 1e-7 times that span, and about 2e-9 is seen. So a skipped material has no native contact, and the 900000-long lead beside a small circle is still checked. Each skipped scan is charged to the native query budget as if it ran. Ray parity is still asked natively unless the lead's midpoint is outside the material's Y range. The pierce zone skips only material farther than its radius by the same gap. A randomized test checks that skipped material never has a native contact. It fails with the span allowance at zero and passes with it as low as 1e-8. Plate 1 of the washer nest now plans Ready in about 54 s. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/LeadMaterialSnapshot.cs | 12 ++++ .../CNC/CuttingPlanning/LeadPathValidator.cs | 37 +++++++++-- .../Diagnostics/PostVerificationGeometry.cs | 34 +++++++++- .../NearbyMaterialCheckTests.cs | 62 ++++++++++++++++++- docs/cutting-planner.md | 14 +++-- 5 files changed, 143 insertions(+), 16 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 3d907c0e..aedf2715 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -17,12 +17,24 @@ public sealed class LeadMaterialSnapshot Rings = rings; Tabs = tabs; Reason = reason; + var extent = PostVerificationGeometry.Extent.None; + foreach (var ring in rings) + foreach (var curve in ring) + extent = extent.Union(curve.Extent); + Extent = extent; + BoundaryCount = rings.Sum(ring => ring.Length); } public bool IsComplete => Reason == null; public string Reason { get; } internal IReadOnlyList Rings { get; } + /// A conservative extent of every boundary's native contact reach; material lies within it. + internal PostVerificationGeometry.Extent Extent { get; } + + /// The boundary curves of all rings: the native queries one full scan charges. + internal int BoundaryCount { get; } + /// /// Per ring curve, whether it is a tabbed cutout's tab: material boundary that the /// program never cuts. Aligned with . diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index 4a2ab75d..98b96497 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -158,13 +158,14 @@ public static class LeadPathValidator return (new(true, false, breakLead ? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs." : $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null); + var reach = move.Curve.Extent; for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); var material = otherMaterials[other]; if (ReferenceEquals(material, target)) continue; - if (OtherContact(material)) + if (reach.IsClearOfQueriesFrom(material.Extent) ? FarInside(material) : OtherContact(material)) return (new(true, false, "Lead contacts or enters another placed material. " + "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. " + "For locked parts, edit the leads or unlock the part before replanning."), other); @@ -183,8 +184,17 @@ public static class LeadPathValidator for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); - if (!ReferenceEquals(otherMaterials[other], target) - && Clearance(otherMaterials[other]) is { } distance) + var material = otherMaterials[other]; + if (ReferenceEquals(material, target)) + continue; + // Every boundary of material this far away is farther than the zone. + if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y) + .IsClearOfQueriesFrom(material.Extent, pierceRadius)) + { + Charge(material.BoundaryCount); + continue; + } + if (Clearance(material) is { } distance) return (Short(distance, "another placed part"), other); } return (null, null); @@ -212,9 +222,7 @@ public static class LeadPathValidator return CheckMaterial(material, null) != null; if (memo.TryGet(move.Curve, material, out var scan)) { - if (budget - scan.Queries < 0) - throw new NotSupportedException("Lead validation exceeds the native query limit."); - budget -= scan.Queries; + Charge(scan.Queries); return scan.Contact; } var before = budget; @@ -222,6 +230,23 @@ public static class LeadPathValidator memo.Add(move.Curve, material, new(contact, before - budget)); return contact; } + + // Material too far from the lead for any native boundary query to report a contact + // (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran. + // Ray parity is still asked natively unless the lead lies beside the material's rows. + bool FarInside(LeadMaterialSnapshot material) + { + Charge(material.BoundaryCount); + var midpoint = move.Curve.Midpoint; + return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token); + } + + void Charge(int queries) + { + if (budget - queries < 0) + throw new NotSupportedException("Lead validation exceeds the native query limit."); + budget -= queries; + } } } catch (Exception ex) when (ex is ArgumentException or NotSupportedException) diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 1f5bbee2..c35b7eef 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -44,9 +44,37 @@ internal static class PostVerificationGeometry /// 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); + internal bool IsClearOf(Extent other) => IsClearOf(other, ClearMargin); + + /// + /// The relative rounding allowance of native line/circle intersections. Their discriminant + /// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times + /// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a + /// spurious root can lie at most about 1e-7 times the span of both extents beyond the true + /// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a + /// small circle needs the full check; a short lead beside a nearby part does not. + /// + internal const double SpanRounding = 1e-6; + + /// + /// As , with a gap that also covers the rounding of native + /// line/circle queries over the span of both extents (), plus + /// . Lead checks skip a material only on this test. + /// + internal bool IsClearOfQueriesFrom(Extent other, double reach = 0) + { + var union = Union(other); + var span = System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY); + return IsClearOf(other, ClearMargin + reach + SpanRounding * span); + } + + /// True when lies more than outside the Y range. + internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit + && (y > MaxY + ClearMargin || y < MinY - ClearMargin); + + private bool IsClearOf(Extent other, double margin) => IsWellConditioned && other.IsWellConditioned + && (MaxX + margin < other.MinX || other.MaxX + margin < MinX + || MaxY + margin < other.MinY || other.MaxY + margin < 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. diff --git a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs index 39b184ca..b26c5369 100644 --- a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs @@ -6,9 +6,10 @@ using OpenNest.Geometry; namespace OpenNest.Tests.CuttingPlanning; /// -/// 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. +/// 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. /// public class NearbyMaterialCheckTests { @@ -135,6 +136,61 @@ public class NearbyMaterialCheckTests 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 centre = start + direction * along + normal * (random.Next(4) == 0 ? 0 : gap); + var program = random.Next(2) == 0 + ? Circle(centre, size) + : LeadPathValidationTests.Rectangle(centre.X - size, centre.Y - size, centre.X + size, centre.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 centre, double radius) + { + var p = new Program(); + p.MoveTo(centre.X + radius, centre.Y); + p.Codes.Add(new ArcMove(new Vector(centre.X + radius, centre.Y), centre, RotationType.CCW)); + return p; + } + } + [Fact] public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing() { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 5077749b..c0d95a5e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -225,10 +225,16 @@ Every candidate rapid is checked against contours already completed, including earlier holes in the same part. Future contours are not yet obstacles. Rapid checks skip completed contours whose extents are more than 0.001 clear of the rapid, ten times the widest band any native contact query allows beyond an -extent; anything closer, touching included, gets the full native check. Lead -checks examine every other part's material: native line/circle queries can report -rounding contacts for long leads far from small circles, and leads keep those -results. Every prefix of a part repeats its earlier contours' leads, so one search +extent; anything closer, touching included, gets the full native check. Native +line/circle queries can report rounding contacts for long leads far from small +circles, and leads keep those results: a lead check skips another part's material +only when their extents are clear by that 0.001 plus 1e-6 times the span of both +extents. The discriminant's rounding error is bounded by about 1e-7 times that span +(about 2e-9 is seen), so a skipped material has no native contact; a 900000-long lead +beside a small circle is still checked. A skipped scan is charged to the native query +budget as if it ran, and ray parity is still asked natively unless the lead's midpoint +lies outside the material's Y range. A pierce zone skips material farther than its +radius by the same gap. Every prefix of a part repeats its earlier contours' leads, so one search remembers each other-material verdict by the lead curve's exact bits and the material's reference, and charges the native query budget the scan ran; replay never uses it. An arc's extent is its whole supporting circle widened to the distance at which the native